@clawpump/claw-agent 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/.dockerignore +67 -0
- package/agent/.envrc +1 -1
- package/agent/.gitattributes +8 -0
- package/agent/AGENTS.md +216 -4
- package/agent/CONTRIBUTING.md +46 -8
- package/agent/Dockerfile +78 -35
- package/agent/MANIFEST.in +2 -0
- package/agent/README.md +12 -5
- package/agent/README.ur-pk.md +261 -0
- package/agent/README.zh-CN.md +11 -8
- package/agent/SECURITY.md +5 -4
- package/agent/acp_adapter/provenance.py +127 -0
- package/agent/acp_adapter/server.py +112 -5
- package/agent/acp_adapter/session.py +1 -6
- package/agent/acp_registry/agent.json +2 -2
- package/agent/agent/account_usage.py +313 -1
- package/agent/agent/agent_init.py +140 -37
- package/agent/agent/agent_runtime_helpers.py +342 -83
- package/agent/agent/anthropic_adapter.py +320 -33
- package/agent/agent/auxiliary_client.py +525 -105
- package/agent/agent/background_review.py +157 -19
- package/agent/agent/bedrock_adapter.py +71 -6
- package/agent/agent/billing_view.py +295 -0
- package/agent/agent/chat_completion_helpers.py +229 -4
- package/agent/agent/codex_responses_adapter.py +86 -10
- package/agent/agent/codex_runtime.py +153 -1
- package/agent/agent/coding_context.py +738 -0
- package/agent/agent/context_compressor.py +392 -44
- package/agent/agent/context_references.py +34 -1
- package/agent/agent/conversation_compression.py +159 -22
- package/agent/agent/conversation_loop.py +643 -908
- package/agent/agent/copilot_acp_client.py +4 -11
- package/agent/agent/credential_pool.py +5 -3
- package/agent/agent/credits_tracker.py +794 -0
- package/agent/agent/curator.py +91 -18
- package/agent/agent/curator_backup.py +26 -10
- package/agent/agent/display.py +42 -1
- package/agent/agent/error_classifier.py +52 -3
- package/agent/agent/errors.py +3 -0
- package/agent/agent/file_safety.py +0 -17
- package/agent/agent/gemini_native_adapter.py +31 -1
- package/agent/agent/i18n.py +48 -4
- package/agent/agent/image_gen_provider.py +74 -5
- package/agent/agent/image_routing.py +29 -0
- package/agent/agent/insights.py +8 -17
- package/agent/agent/lsp/install.py +3 -0
- package/agent/agent/memory_manager.py +326 -31
- package/agent/agent/message_content.py +50 -0
- package/agent/agent/model_metadata.py +214 -3
- package/agent/agent/moonshot_schema.py +8 -1
- package/agent/agent/onboarding.py +60 -0
- package/agent/agent/prompt_builder.py +327 -37
- package/agent/agent/redact.py +1 -0
- package/agent/agent/runtime_cwd.py +34 -5
- package/agent/agent/secret_scope.py +205 -0
- package/agent/agent/secret_sources/bitwarden.py +34 -2
- package/agent/agent/skill_commands.py +90 -1
- package/agent/agent/skill_preprocessing.py +1 -0
- package/agent/agent/skill_utils.py +209 -36
- package/agent/agent/ssl_guard.py +94 -0
- package/agent/agent/system_prompt.py +133 -5
- package/agent/agent/tool_executor.py +496 -70
- package/agent/agent/transports/anthropic.py +83 -21
- package/agent/agent/transports/chat_completions.py +94 -5
- package/agent/agent/transports/codex.py +67 -2
- package/agent/agent/transports/codex_app_server.py +1 -0
- package/agent/agent/transports/codex_app_server_session.py +30 -0
- package/agent/agent/transports/types.py +12 -0
- package/agent/agent/turn_context.py +408 -0
- package/agent/agent/turn_finalizer.py +428 -0
- package/agent/agent/turn_retry_state.py +68 -0
- package/agent/agent/usage_pricing.py +3 -0
- package/agent/apps/bootstrap-installer/package.json +6 -5
- package/agent/apps/bootstrap-installer/src/routes/failure.tsx +12 -5
- package/agent/apps/bootstrap-installer/src/routes/progress.tsx +1 -3
- package/agent/apps/bootstrap-installer/src/store.ts +3 -2
- package/agent/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +172 -7
- package/agent/apps/bootstrap-installer/src-tauri/src/events.rs +14 -1
- package/agent/apps/bootstrap-installer/src-tauri/src/paths.rs +29 -0
- package/agent/apps/bootstrap-installer/src-tauri/src/powershell.rs +93 -3
- package/agent/apps/bootstrap-installer/src-tauri/src/update.rs +695 -39
- package/agent/apps/bootstrap-installer/tsconfig.json +3 -4
- package/agent/apps/desktop/DESIGN.md +167 -0
- package/agent/apps/desktop/README.md +20 -16
- package/agent/apps/desktop/assets/icon.icns +0 -0
- package/agent/apps/desktop/assets/icon.ico +0 -0
- package/agent/apps/desktop/assets/icon.png +0 -0
- package/agent/apps/desktop/electron/backend-env.cjs +112 -0
- package/agent/apps/desktop/electron/backend-env.test.cjs +111 -0
- package/agent/apps/desktop/electron/backend-probes.test.cjs +3 -1
- package/agent/apps/desktop/electron/backend-ready.cjs +66 -0
- package/agent/apps/desktop/electron/bootstrap-platform.cjs +52 -0
- package/agent/apps/desktop/electron/bootstrap-platform.test.cjs +59 -1
- package/agent/apps/desktop/electron/bootstrap-runner.cjs +176 -38
- package/agent/apps/desktop/electron/bootstrap-runner.test.cjs +112 -1
- package/agent/apps/desktop/electron/connection-config.cjs +288 -0
- package/agent/apps/desktop/electron/connection-config.test.cjs +396 -0
- package/agent/apps/desktop/electron/dashboard-token.cjs +99 -0
- package/agent/apps/desktop/electron/dashboard-token.test.cjs +142 -0
- package/agent/apps/desktop/electron/desktop-uninstall.cjs +232 -0
- package/agent/apps/desktop/electron/desktop-uninstall.test.cjs +246 -0
- package/agent/apps/desktop/electron/entitlements.mac.inherit.plist +2 -0
- package/agent/apps/desktop/electron/fs-read-dir.cjs +109 -0
- package/agent/apps/desktop/electron/fs-read-dir.test.cjs +364 -0
- package/agent/apps/desktop/electron/gateway-ws-probe.cjs +188 -0
- package/agent/apps/desktop/electron/gateway-ws-probe.test.cjs +122 -0
- package/agent/apps/desktop/electron/git-root.cjs +54 -0
- package/agent/apps/desktop/electron/git-root.test.cjs +40 -0
- package/agent/apps/desktop/electron/git-worktrees.cjs +174 -0
- package/agent/apps/desktop/electron/hardening.cjs +123 -28
- package/agent/apps/desktop/electron/hardening.test.cjs +163 -0
- package/agent/apps/desktop/electron/main.cjs +3121 -331
- package/agent/apps/desktop/electron/oauth-net-request.cjs +20 -0
- package/agent/apps/desktop/electron/oauth-net-request.test.cjs +34 -0
- package/agent/apps/desktop/electron/preload.cjs +52 -2
- package/agent/apps/desktop/electron/session-windows.cjs +124 -0
- package/agent/apps/desktop/electron/session-windows.test.cjs +199 -0
- package/agent/apps/desktop/electron/update-rebuild.cjs +29 -0
- package/agent/apps/desktop/electron/update-rebuild.test.cjs +55 -0
- package/agent/apps/desktop/electron/update-remote.cjs +56 -0
- package/agent/apps/desktop/electron/update-remote.test.cjs +78 -0
- package/agent/apps/desktop/electron/vscode-marketplace.cjs +331 -0
- package/agent/apps/desktop/electron/vscode-marketplace.test.cjs +113 -0
- package/agent/apps/desktop/electron/windows-child-process.test.cjs +57 -0
- package/agent/apps/desktop/electron/windows-user-env.cjs +76 -0
- package/agent/apps/desktop/electron/windows-user-env.test.cjs +90 -0
- package/agent/apps/desktop/electron/workspace-cwd.cjs +38 -0
- package/agent/apps/desktop/electron/workspace-cwd.test.cjs +45 -0
- package/agent/apps/desktop/eslint.config.mjs +0 -3
- package/agent/apps/desktop/index.html +27 -2
- package/agent/apps/desktop/package.json +31 -11
- package/agent/apps/desktop/pr-assets/session-source-folders.png +0 -0
- package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
- package/agent/apps/desktop/public/nous-girl.jpg +0 -0
- package/agent/apps/desktop/scripts/assert-dist-built.cjs +70 -0
- package/agent/apps/desktop/scripts/assert-dist-built.test.cjs +84 -0
- package/agent/apps/desktop/scripts/before-pack.cjs +78 -0
- package/agent/apps/desktop/scripts/before-pack.test.cjs +53 -0
- package/agent/apps/desktop/scripts/diag-scroll-reset.mjs +229 -0
- package/agent/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +64 -0
- package/agent/apps/desktop/scripts/run-electron-builder.cjs +57 -0
- package/agent/apps/desktop/src/app/agents/index.tsx +53 -45
- package/agent/apps/desktop/src/app/artifacts/index.tsx +102 -83
- package/agent/apps/desktop/src/app/chat/chat-drop-overlay.tsx +29 -8
- package/agent/apps/desktop/src/app/chat/chat-swap-overlay.tsx +47 -0
- package/agent/apps/desktop/src/app/chat/composer/attachments.tsx +81 -45
- package/agent/apps/desktop/src/app/chat/composer/completion-drawer.tsx +13 -24
- package/agent/apps/desktop/src/app/chat/composer/context-menu.tsx +138 -88
- package/agent/apps/desktop/src/app/chat/composer/controls.tsx +138 -90
- package/agent/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
- package/agent/apps/desktop/src/app/chat/composer/focus.ts +32 -0
- package/agent/apps/desktop/src/app/chat/composer/help-hint.tsx +38 -25
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +7 -0
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +22 -12
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +142 -14
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +14 -11
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +9 -6
- package/agent/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
- package/agent/apps/desktop/src/app/chat/composer/index.tsx +930 -180
- package/agent/apps/desktop/src/app/chat/composer/inline-refs.ts +136 -32
- package/agent/apps/desktop/src/app/chat/composer/model-pill.tsx +86 -0
- package/agent/apps/desktop/src/app/chat/composer/queue-panel.tsx +54 -75
- package/agent/apps/desktop/src/app/chat/composer/rich-editor.test.ts +117 -1
- package/agent/apps/desktop/src/app/chat/composer/rich-editor.ts +117 -6
- package/agent/apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
- package/agent/apps/desktop/src/app/chat/composer/status-stack/index.tsx +202 -0
- package/agent/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +155 -0
- package/agent/apps/desktop/src/app/chat/composer/text-utils.test.ts +104 -0
- package/agent/apps/desktop/src/app/chat/composer/text-utils.ts +37 -9
- package/agent/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +50 -0
- package/agent/apps/desktop/src/app/chat/composer/trigger-popover.tsx +105 -40
- package/agent/apps/desktop/src/app/chat/composer/types.ts +5 -0
- package/agent/apps/desktop/src/app/chat/composer/url-dialog.tsx +11 -15
- package/agent/apps/desktop/src/app/chat/composer/voice-activity.tsx +8 -4
- package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
- package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +70 -16
- package/agent/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +52 -16
- package/agent/apps/desktop/src/app/chat/index.tsx +234 -81
- package/agent/apps/desktop/src/app/chat/perf-probe.tsx +69 -21
- package/agent/apps/desktop/src/app/chat/right-rail/preview-console.tsx +44 -40
- package/agent/apps/desktop/src/app/chat/right-rail/preview-file.tsx +71 -25
- package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +40 -1
- package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +55 -53
- package/agent/apps/desktop/src/app/chat/right-rail/preview.tsx +35 -17
- package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
- package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +74 -0
- package/agent/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +356 -0
- package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +1189 -364
- package/agent/apps/desktop/src/app/chat/sidebar/load-more-row.tsx +30 -0
- package/agent/apps/desktop/src/app/chat/sidebar/order.test.ts +21 -0
- package/agent/apps/desktop/src/app/chat/sidebar/order.ts +17 -0
- package/agent/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +524 -0
- package/agent/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +80 -45
- package/agent/apps/desktop/src/app/chat/sidebar/session-row.tsx +120 -25
- package/agent/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +7 -13
- package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
- package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.ts +326 -0
- package/agent/apps/desktop/src/app/chat/thread-loading.ts +7 -2
- package/agent/apps/desktop/src/app/command-center/index.tsx +320 -581
- package/agent/apps/desktop/src/app/command-palette/index.tsx +681 -0
- package/agent/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +157 -0
- package/agent/apps/desktop/src/app/cron/index.tsx +392 -324
- package/agent/apps/desktop/src/app/cron/job-state.ts +29 -0
- package/agent/apps/desktop/src/app/desktop-controller.tsx +618 -123
- package/agent/apps/desktop/src/app/floating-hud.ts +22 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +260 -14
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +48 -4
- package/agent/apps/desktop/src/app/hooks/use-keybinds.ts +270 -0
- package/agent/apps/desktop/src/app/hooks/use-refresh-hotkey.ts +45 -0
- package/agent/apps/desktop/src/app/layout-constants.ts +19 -0
- package/agent/apps/desktop/src/app/messaging/index.tsx +136 -241
- package/agent/apps/desktop/src/app/messaging/platform-icon.tsx +95 -0
- package/agent/apps/desktop/src/app/model-visibility-overlay.tsx +31 -0
- package/agent/apps/desktop/src/app/overlays/overlay-search-input.tsx +18 -62
- package/agent/apps/desktop/src/app/overlays/overlay-split-layout.tsx +59 -7
- package/agent/apps/desktop/src/app/overlays/overlay-view.tsx +9 -5
- package/agent/apps/desktop/src/app/page-search-shell.tsx +42 -20
- package/agent/apps/desktop/src/app/profiles/create-profile-dialog.tsx +165 -0
- package/agent/apps/desktop/src/app/profiles/delete-profile-dialog.tsx +65 -0
- package/agent/apps/desktop/src/app/profiles/index.tsx +174 -199
- package/agent/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +125 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts +27 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/ipc.test.ts +100 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/ipc.ts +12 -18
- package/agent/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +177 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/tree.tsx +35 -21
- package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +75 -3
- package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +152 -5
- package/agent/apps/desktop/src/app/right-sidebar/index.test.tsx +75 -0
- package/agent/apps/desktop/src/app/right-sidebar/index.tsx +166 -129
- package/agent/apps/desktop/src/app/right-sidebar/store.ts +19 -4
- package/agent/apps/desktop/src/app/right-sidebar/terminal/buffer.ts +65 -0
- package/agent/apps/desktop/src/app/right-sidebar/terminal/index.tsx +29 -34
- package/agent/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +18 -6
- package/agent/apps/desktop/src/app/right-sidebar/terminal/selection.ts +93 -32
- package/agent/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +381 -119
- package/agent/apps/desktop/src/app/routes.ts +9 -0
- package/agent/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +17 -7
- package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +365 -47
- package/agent/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +198 -0
- package/agent/apps/desktop/src/app/session/hooks/use-model-controls.ts +70 -34
- package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +1061 -0
- package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +1143 -165
- package/agent/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +341 -2
- package/agent/apps/desktop/src/app/session/hooks/use-route-resume.ts +176 -5
- package/agent/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +259 -0
- package/agent/apps/desktop/src/app/session/hooks/use-session-actions.ts +452 -149
- package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +327 -0
- package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +133 -4
- package/agent/apps/desktop/src/app/session-picker-overlay.tsx +32 -0
- package/agent/apps/desktop/src/app/session-switcher.tsx +107 -0
- package/agent/apps/desktop/src/app/settings/about-settings.tsx +45 -36
- package/agent/apps/desktop/src/app/settings/appearance-settings.tsx +243 -162
- package/agent/apps/desktop/src/app/settings/config-settings.tsx +86 -66
- package/agent/apps/desktop/src/app/settings/constants.ts +459 -122
- package/agent/apps/desktop/src/app/settings/credential-key-ui.tsx +373 -0
- package/agent/apps/desktop/src/app/settings/env-credentials.tsx +198 -0
- package/agent/apps/desktop/src/app/settings/env-var-actions-menu.tsx +136 -0
- package/agent/apps/desktop/src/app/settings/field-copy.ts +56 -0
- package/agent/apps/desktop/src/app/settings/gateway-settings.tsx +385 -72
- package/agent/apps/desktop/src/app/settings/helpers.test.ts +156 -1
- package/agent/apps/desktop/src/app/settings/helpers.ts +30 -2
- package/agent/apps/desktop/src/app/settings/index.tsx +118 -84
- package/agent/apps/desktop/src/app/settings/keys-settings.tsx +62 -419
- package/agent/apps/desktop/src/app/settings/mcp-settings.tsx +65 -60
- package/agent/apps/desktop/src/app/settings/model-settings.test.tsx +129 -5
- package/agent/apps/desktop/src/app/settings/model-settings.tsx +370 -65
- package/agent/apps/desktop/src/app/settings/notifications-settings.tsx +150 -0
- package/agent/apps/desktop/src/app/settings/primitives.tsx +5 -11
- package/agent/apps/desktop/src/app/settings/provider-config-panel.test.tsx +142 -0
- package/agent/apps/desktop/src/app/settings/provider-config-panel.tsx +182 -0
- package/agent/apps/desktop/src/app/settings/providers-settings.test.tsx +171 -0
- package/agent/apps/desktop/src/app/settings/providers-settings.tsx +471 -0
- package/agent/apps/desktop/src/app/settings/sessions-settings.tsx +183 -71
- package/agent/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +135 -1
- package/agent/apps/desktop/src/app/settings/toolset-config-panel.tsx +180 -57
- package/agent/apps/desktop/src/app/settings/types.ts +9 -6
- package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +185 -0
- package/agent/apps/desktop/src/app/settings/use-deep-link-highlight.ts +60 -0
- package/agent/apps/desktop/src/app/shell/app-shell.tsx +59 -13
- package/agent/apps/desktop/src/app/shell/gateway-menu-panel.tsx +37 -32
- package/agent/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +6 -3
- package/agent/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +212 -53
- package/agent/apps/desktop/src/app/shell/keybind-panel.tsx +215 -0
- package/agent/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +84 -0
- package/agent/apps/desktop/src/app/shell/model-edit-submenu.tsx +244 -0
- package/agent/apps/desktop/src/app/shell/model-menu-panel.tsx +392 -0
- package/agent/apps/desktop/src/app/shell/statusbar-controls.tsx +23 -33
- package/agent/apps/desktop/src/app/shell/titlebar-controls.tsx +79 -95
- package/agent/apps/desktop/src/app/shell/titlebar.ts +8 -2
- package/agent/apps/desktop/src/app/skills/index.test.tsx +11 -0
- package/agent/apps/desktop/src/app/skills/index.tsx +79 -64
- package/agent/apps/desktop/src/app/types.ts +85 -0
- package/agent/apps/desktop/src/app/updates-overlay.tsx +110 -105
- package/agent/apps/desktop/src/components/assistant-ui/ansi-text.tsx +34 -0
- package/agent/apps/desktop/src/components/assistant-ui/block-direction.test.tsx +129 -0
- package/agent/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +102 -81
- package/agent/apps/desktop/src/components/assistant-ui/directive-text.tsx +92 -15
- package/agent/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +38 -0
- package/agent/apps/desktop/src/components/assistant-ui/markdown-text.tsx +304 -45
- package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
- package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +48 -0
- package/agent/apps/desktop/src/components/assistant-ui/streaming.test.tsx +142 -90
- package/agent/apps/desktop/src/components/assistant-ui/thread-list.tsx +337 -0
- package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +667 -190
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +299 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +133 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval.tsx +239 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +31 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +152 -134
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +142 -150
- package/agent/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx +14 -12
- package/agent/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
- package/agent/apps/desktop/src/components/assistant-ui/user-message-text.tsx +152 -0
- package/agent/apps/desktop/src/components/boot-failure-overlay.tsx +150 -33
- package/agent/apps/desktop/src/components/boot-failure-reauth.test.ts +100 -0
- package/agent/apps/desktop/src/components/boot-failure-reauth.ts +81 -0
- package/agent/apps/desktop/src/components/brand-mark.tsx +19 -0
- package/agent/apps/desktop/src/components/chat/code-card.tsx +1 -1
- package/agent/apps/desktop/src/components/chat/composer-dock.ts +31 -0
- package/agent/apps/desktop/src/components/chat/diff-lines.tsx +1 -1
- package/agent/apps/desktop/src/components/chat/disclosure-row.tsx +13 -3
- package/agent/apps/desktop/src/components/chat/expandable-block.tsx +52 -0
- package/agent/apps/desktop/src/components/chat/generated-image-result.tsx +174 -0
- package/agent/apps/desktop/src/components/chat/image-generation-placeholder.tsx +70 -37
- package/agent/apps/desktop/src/components/chat/intro.tsx +8 -7
- package/agent/apps/desktop/src/components/chat/preview-attachment.tsx +4 -2
- package/agent/apps/desktop/src/components/chat/shiki-highlighter.test.ts +37 -0
- package/agent/apps/desktop/src/components/chat/shiki-highlighter.tsx +96 -22
- package/agent/apps/desktop/src/components/chat/status-row.tsx +70 -0
- package/agent/apps/desktop/src/components/chat/status-section.tsx +42 -0
- package/agent/apps/desktop/src/components/chat/terminal-output.tsx +54 -0
- package/agent/apps/desktop/src/components/chat/zoomable-image.tsx +70 -109
- package/agent/apps/desktop/src/components/desktop-install-overlay.tsx +154 -84
- package/agent/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +38 -8
- package/agent/apps/desktop/src/components/desktop-onboarding-overlay.tsx +789 -233
- package/agent/apps/desktop/src/components/error-boundary.tsx +77 -0
- package/agent/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +144 -0
- package/agent/apps/desktop/src/components/gateway-connecting-overlay.tsx +7 -1
- package/agent/apps/desktop/src/components/haptics-provider.tsx +24 -0
- package/agent/apps/desktop/src/components/language-switcher.test.tsx +53 -0
- package/agent/apps/desktop/src/components/language-switcher.tsx +175 -0
- package/agent/apps/desktop/src/components/model-picker.tsx +42 -40
- package/agent/apps/desktop/src/components/model-visibility-dialog.tsx +166 -0
- package/agent/apps/desktop/src/components/notifications.tsx +48 -27
- package/agent/apps/desktop/src/components/pane-shell/index.ts +1 -1
- package/agent/apps/desktop/src/components/pane-shell/pane-shell.tsx +146 -9
- package/agent/apps/desktop/src/components/prompt-overlays.tsx +234 -0
- package/agent/apps/desktop/src/components/session-picker.tsx +108 -0
- package/agent/apps/desktop/src/components/ui/action-status.tsx +25 -0
- package/agent/apps/desktop/src/components/ui/badge.tsx +35 -0
- package/agent/apps/desktop/src/components/ui/button.tsx +37 -13
- package/agent/apps/desktop/src/components/ui/confirm-dialog.tsx +109 -0
- package/agent/apps/desktop/src/components/ui/control.ts +25 -0
- package/agent/apps/desktop/src/components/ui/copy-button.test.tsx +36 -0
- package/agent/apps/desktop/src/components/ui/copy-button.tsx +38 -27
- package/agent/apps/desktop/src/components/ui/dialog.tsx +39 -11
- package/agent/apps/desktop/src/components/ui/dropdown-menu.tsx +98 -24
- package/agent/apps/desktop/src/components/ui/error-state.tsx +50 -0
- package/agent/apps/desktop/src/components/ui/fade-text.tsx +9 -2
- package/agent/apps/desktop/src/components/ui/{braille-spinner.tsx → glyph-spinner.tsx} +15 -13
- package/agent/apps/desktop/src/components/ui/input.tsx +5 -2
- package/agent/apps/desktop/src/components/ui/kbd.tsx +83 -12
- package/agent/apps/desktop/src/components/ui/log-view.tsx +19 -0
- package/agent/apps/desktop/src/components/ui/pagination.tsx +12 -5
- package/agent/apps/desktop/src/components/ui/popover.tsx +44 -0
- package/agent/apps/desktop/src/components/ui/search-field.tsx +80 -0
- package/agent/apps/desktop/src/components/ui/segmented-control.tsx +51 -0
- package/agent/apps/desktop/src/components/ui/select.tsx +10 -3
- package/agent/apps/desktop/src/components/ui/sheet.tsx +8 -2
- package/agent/apps/desktop/src/components/ui/sidebar.tsx +18 -25
- package/agent/apps/desktop/src/components/ui/switch.tsx +38 -15
- package/agent/apps/desktop/src/components/ui/textarea.tsx +4 -11
- package/agent/apps/desktop/src/components/ui/tool-icon.tsx +65 -0
- package/agent/apps/desktop/src/components/ui/tooltip.tsx +31 -4
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/agent/apps/desktop/src/global.d.ts +181 -4
- package/agent/apps/desktop/src/hermes.test.ts +60 -0
- package/agent/apps/desktop/src/hermes.ts +190 -13
- package/agent/apps/desktop/src/hooks/use-image-download.ts +85 -0
- package/agent/apps/desktop/src/hooks/use-resize-observer.ts +13 -4
- package/agent/apps/desktop/src/hooks/use-worktree-info.ts +68 -0
- package/agent/apps/desktop/src/i18n/catalog.ts +12 -0
- package/agent/apps/desktop/src/i18n/context.test.tsx +232 -0
- package/agent/apps/desktop/src/i18n/context.tsx +183 -0
- package/agent/apps/desktop/src/i18n/define-locale.ts +41 -0
- package/agent/apps/desktop/src/i18n/en.ts +1921 -0
- package/agent/apps/desktop/src/i18n/index.ts +20 -0
- package/agent/apps/desktop/src/i18n/ja.ts +2053 -0
- package/agent/apps/desktop/src/i18n/languages.test.ts +43 -0
- package/agent/apps/desktop/src/i18n/languages.ts +86 -0
- package/agent/apps/desktop/src/i18n/runtime.test.ts +75 -0
- package/agent/apps/desktop/src/i18n/runtime.ts +53 -0
- package/agent/apps/desktop/src/i18n/types.ts +1559 -0
- package/agent/apps/desktop/src/i18n/zh-hant.ts +1992 -0
- package/agent/apps/desktop/src/i18n/zh.ts +2099 -0
- package/agent/apps/desktop/src/lib/ansi.test.ts +123 -0
- package/agent/apps/desktop/src/lib/ansi.ts +186 -0
- package/agent/apps/desktop/src/lib/chat-messages.test.ts +79 -0
- package/agent/apps/desktop/src/lib/chat-messages.ts +68 -29
- package/agent/apps/desktop/src/lib/chat-runtime.test.ts +65 -1
- package/agent/apps/desktop/src/lib/chat-runtime.ts +39 -3
- package/agent/apps/desktop/src/lib/completion-sound.ts +519 -0
- package/agent/apps/desktop/src/lib/desktop-fs.test.ts +116 -0
- package/agent/apps/desktop/src/lib/desktop-fs.ts +113 -0
- package/agent/apps/desktop/src/lib/desktop-slash-commands.test.ts +89 -6
- package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +270 -131
- package/agent/apps/desktop/src/lib/external-link.test.tsx +27 -0
- package/agent/apps/desktop/src/lib/external-link.tsx +9 -2
- package/agent/apps/desktop/src/lib/gateway-events.test.ts +27 -0
- package/agent/apps/desktop/src/lib/gateway-events.ts +16 -0
- package/agent/apps/desktop/src/lib/gateway-ws-url.test.ts +78 -0
- package/agent/apps/desktop/src/lib/gateway-ws-url.ts +91 -0
- package/agent/apps/desktop/src/lib/generated-images.test.ts +97 -0
- package/agent/apps/desktop/src/lib/generated-images.ts +116 -0
- package/agent/apps/desktop/src/lib/haptics.ts +17 -0
- package/agent/apps/desktop/src/lib/icons.ts +10 -2
- package/agent/apps/desktop/src/lib/keybinds/actions.ts +137 -0
- package/agent/apps/desktop/src/lib/keybinds/combo.test.ts +86 -0
- package/agent/apps/desktop/src/lib/keybinds/combo.ts +195 -0
- package/agent/apps/desktop/src/lib/local-preview.ts +23 -2
- package/agent/apps/desktop/src/lib/markdown-preprocess.ts +20 -7
- package/agent/apps/desktop/src/lib/media.remote.test.ts +90 -0
- package/agent/apps/desktop/src/lib/media.ts +40 -1
- package/agent/apps/desktop/src/lib/model-status-label.test.ts +59 -0
- package/agent/apps/desktop/src/lib/model-status-label.ts +122 -0
- package/agent/apps/desktop/src/lib/mutable-ref.ts +6 -0
- package/agent/apps/desktop/src/lib/profile-color.ts +58 -0
- package/agent/apps/desktop/src/lib/query-client.ts +13 -0
- package/agent/apps/desktop/src/lib/remend-tail.test.ts +105 -0
- package/agent/apps/desktop/src/lib/remend-tail.ts +108 -0
- package/agent/apps/desktop/src/lib/session-export.ts +6 -3
- package/agent/apps/desktop/src/lib/session-ids.test.ts +44 -0
- package/agent/apps/desktop/src/lib/session-ids.ts +26 -0
- package/agent/apps/desktop/src/lib/session-search.test.ts +66 -0
- package/agent/apps/desktop/src/lib/session-search.ts +21 -0
- package/agent/apps/desktop/src/lib/session-source.ts +126 -0
- package/agent/apps/desktop/src/lib/storage.test.ts +25 -0
- package/agent/apps/desktop/src/lib/storage.ts +35 -1
- package/agent/apps/desktop/src/lib/todos.test.ts +46 -1
- package/agent/apps/desktop/src/lib/todos.ts +37 -0
- package/agent/apps/desktop/src/lib/tool-result-summary.ts +5 -1
- package/agent/apps/desktop/src/lib/update-copy.test.ts +38 -0
- package/agent/apps/desktop/src/lib/update-copy.ts +44 -0
- package/agent/apps/desktop/src/lib/use-enter-animation.ts +2 -2
- package/agent/apps/desktop/src/lib/yolo-session.ts +50 -0
- package/agent/apps/desktop/src/main.tsx +19 -19
- package/agent/apps/desktop/src/store/boot.ts +4 -3
- package/agent/apps/desktop/src/store/clarify.test.ts +81 -0
- package/agent/apps/desktop/src/store/clarify.ts +50 -13
- package/agent/apps/desktop/src/store/command-palette.ts +20 -0
- package/agent/apps/desktop/src/store/compaction.test.ts +53 -0
- package/agent/apps/desktop/src/store/compaction.ts +38 -0
- package/agent/apps/desktop/src/store/completion-sound.ts +32 -0
- package/agent/apps/desktop/src/store/composer-input-history.test.ts +147 -0
- package/agent/apps/desktop/src/store/composer-input-history.ts +158 -0
- package/agent/apps/desktop/src/store/composer-queue.test.ts +68 -0
- package/agent/apps/desktop/src/store/composer-queue.ts +76 -0
- package/agent/apps/desktop/src/store/composer-status.test.ts +99 -0
- package/agent/apps/desktop/src/store/composer-status.ts +277 -0
- package/agent/apps/desktop/src/store/composer.test.ts +106 -0
- package/agent/apps/desktop/src/store/composer.ts +116 -0
- package/agent/apps/desktop/src/store/cron.ts +19 -0
- package/agent/apps/desktop/src/store/gateway.ts +280 -6
- package/agent/apps/desktop/src/store/keybinds.ts +143 -0
- package/agent/apps/desktop/src/store/layout.ts +107 -9
- package/agent/apps/desktop/src/store/model-presets.test.ts +51 -0
- package/agent/apps/desktop/src/store/model-presets.ts +86 -0
- package/agent/apps/desktop/src/store/model-visibility.test.ts +99 -0
- package/agent/apps/desktop/src/store/model-visibility.ts +161 -0
- package/agent/apps/desktop/src/store/native-notifications.test.ts +192 -0
- package/agent/apps/desktop/src/store/native-notifications.ts +203 -0
- package/agent/apps/desktop/src/store/notifications.ts +10 -7
- package/agent/apps/desktop/src/store/onboarding.test.ts +271 -1
- package/agent/apps/desktop/src/store/onboarding.ts +268 -38
- package/agent/apps/desktop/src/store/preview.ts +10 -1
- package/agent/apps/desktop/src/store/profile.test.ts +89 -0
- package/agent/apps/desktop/src/store/profile.ts +395 -0
- package/agent/apps/desktop/src/store/prompts.test.ts +127 -0
- package/agent/apps/desktop/src/store/prompts.ts +117 -0
- package/agent/apps/desktop/src/store/session-switcher.test.ts +115 -0
- package/agent/apps/desktop/src/store/session-switcher.ts +128 -0
- package/agent/apps/desktop/src/store/session-sync.ts +25 -0
- package/agent/apps/desktop/src/store/session.test.ts +268 -2
- package/agent/apps/desktop/src/store/session.ts +392 -18
- package/agent/apps/desktop/src/store/subagents.ts +3 -0
- package/agent/apps/desktop/src/store/system-actions.ts +48 -0
- package/agent/apps/desktop/src/store/thread-scroll.ts +58 -5
- package/agent/apps/desktop/src/store/todos.test.ts +47 -0
- package/agent/apps/desktop/src/store/todos.ts +64 -0
- package/agent/apps/desktop/src/store/tool-dismiss.ts +45 -0
- package/agent/apps/desktop/src/store/translucency.ts +38 -0
- package/agent/apps/desktop/src/store/updates.test.ts +187 -2
- package/agent/apps/desktop/src/store/updates.ts +268 -18
- package/agent/apps/desktop/src/store/windows.test.ts +143 -0
- package/agent/apps/desktop/src/store/windows.ts +115 -0
- package/agent/apps/desktop/src/styles.css +510 -119
- package/agent/apps/desktop/src/themes/color.ts +142 -0
- package/agent/apps/desktop/src/themes/context.tsx +128 -75
- package/agent/apps/desktop/src/themes/install.test.ts +119 -0
- package/agent/apps/desktop/src/themes/install.ts +95 -0
- package/agent/apps/desktop/src/themes/presets.test.ts +33 -0
- package/agent/apps/desktop/src/themes/presets.ts +13 -4
- package/agent/apps/desktop/src/themes/profile-theme.test.ts +41 -0
- package/agent/apps/desktop/src/themes/types.ts +35 -0
- package/agent/apps/desktop/src/themes/user-themes.test.ts +63 -0
- package/agent/apps/desktop/src/themes/user-themes.ts +122 -0
- package/agent/apps/desktop/src/themes/vscode.test.ts +171 -0
- package/agent/apps/desktop/src/themes/vscode.ts +343 -0
- package/agent/apps/desktop/src/types/hermes.ts +138 -1
- package/agent/apps/desktop/tsconfig.json +2 -2
- package/agent/apps/desktop/vite.config.ts +18 -0
- package/agent/apps/shared/package.json +1 -1
- package/agent/apps/shared/src/json-rpc-gateway.ts +63 -2
- package/agent/apps/shared/tsconfig.json +2 -2
- package/agent/cli-config.yaml.example +78 -1
- package/agent/cli.py +2294 -3146
- package/agent/cron/blueprint_catalog.py +713 -0
- package/agent/cron/jobs.py +226 -110
- package/agent/cron/scheduler.py +468 -193
- package/agent/cron/scheduler_provider.py +177 -0
- package/agent/cron/scripts/__init__.py +1 -0
- package/agent/cron/scripts/classify_items.py +226 -0
- package/agent/cron/suggestion_catalog.py +154 -0
- package/agent/cron/suggestions.py +257 -0
- package/agent/docs/chronos-managed-cron-contract.md +196 -0
- package/agent/docs/design/profile-builder.md +146 -0
- package/agent/docs/middleware/README.md +260 -0
- package/agent/docs/observability/README.md +316 -0
- package/agent/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md +240 -0
- package/agent/docs/rca-ssl-cacert-post-git-pull.md +54 -0
- package/agent/docs/relay-connector-contract.md +285 -0
- package/agent/gateway/authz_mixin.py +536 -0
- package/agent/gateway/channel_directory.py +65 -3
- package/agent/gateway/config.py +222 -12
- package/agent/gateway/display_config.py +10 -0
- package/agent/gateway/hooks.py +17 -0
- package/agent/gateway/kanban_watchers.py +1146 -0
- package/agent/gateway/message_timestamps.py +166 -0
- package/agent/gateway/platforms/ADDING_A_PLATFORM.md +29 -0
- package/agent/gateway/platforms/api_server.py +216 -38
- package/agent/gateway/platforms/base.py +210 -58
- package/agent/gateway/platforms/email.py +122 -12
- package/agent/gateway/platforms/feishu.py +80 -11
- package/agent/gateway/platforms/feishu_meeting_invite.py +212 -0
- package/agent/gateway/platforms/matrix.py +1498 -297
- package/agent/gateway/platforms/qqbot/adapter.py +6 -0
- package/agent/gateway/platforms/signal.py +8 -0
- package/agent/gateway/platforms/slack.py +308 -12
- package/agent/gateway/platforms/telegram.py +831 -24
- package/agent/gateway/platforms/webhook.py +109 -21
- package/agent/gateway/platforms/weixin.py +113 -2
- package/agent/gateway/platforms/whatsapp.py +94 -288
- package/agent/gateway/platforms/whatsapp_cloud.py +1956 -0
- package/agent/gateway/platforms/whatsapp_common.py +367 -0
- package/agent/gateway/platforms/yuanbao.py +608 -191
- package/agent/gateway/platforms/yuanbao_proto.py +232 -23
- package/agent/gateway/relay/__init__.py +375 -0
- package/agent/gateway/relay/adapter.py +222 -0
- package/agent/gateway/relay/auth.py +168 -0
- package/agent/gateway/relay/descriptor.py +118 -0
- package/agent/gateway/relay/transport.py +101 -0
- package/agent/gateway/relay/ws_transport.py +327 -0
- package/agent/gateway/response_filters.py +53 -0
- package/agent/gateway/rich_sent_store.py +80 -0
- package/agent/gateway/run.py +2940 -5001
- package/agent/gateway/session.py +109 -8
- package/agent/gateway/session_context.py +22 -4
- package/agent/gateway/slash_commands.py +3854 -0
- package/agent/gateway/status.py +141 -21
- package/agent/gateway/stream_consumer.py +288 -31
- package/agent/hermes-already-has-routines.md +1 -1
- package/agent/hermes_cli/__init__.py +62 -17
- package/agent/hermes_cli/_parser.py +30 -0
- package/agent/hermes_cli/_subprocess_compat.py +61 -0
- package/agent/hermes_cli/active_sessions.py +320 -0
- package/agent/hermes_cli/auth.py +707 -59
- package/agent/hermes_cli/auth_commands.py +39 -22
- package/agent/hermes_cli/backup.py +109 -7
- package/agent/hermes_cli/banner.py +88 -0
- package/agent/hermes_cli/blueprint_cmd.py +318 -0
- package/agent/hermes_cli/clawpump_cli.py +3 -3
- package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
- package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
- package/agent/hermes_cli/commands.py +216 -91
- package/agent/hermes_cli/config.py +967 -130
- package/agent/hermes_cli/container_boot.py +76 -11
- package/agent/hermes_cli/cron.py +5 -11
- package/agent/hermes_cli/curator.py +21 -0
- package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
- package/agent/hermes_cli/dashboard_auth/base.py +62 -0
- package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
- package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
- package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
- package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
- package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
- package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
- package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
- package/agent/hermes_cli/dashboard_register.py +427 -0
- package/agent/hermes_cli/debug.py +155 -50
- package/agent/hermes_cli/distribution.py +227 -0
- package/agent/hermes_cli/doctor.py +255 -14
- package/agent/hermes_cli/dump.py +60 -6
- package/agent/hermes_cli/env_loader.py +33 -0
- package/agent/hermes_cli/gateway.py +755 -103
- package/agent/hermes_cli/gateway_enroll.py +250 -0
- package/agent/hermes_cli/gateway_windows.py +254 -11
- package/agent/hermes_cli/gui_uninstall.py +285 -0
- package/agent/hermes_cli/inventory.py +105 -4
- package/agent/hermes_cli/kanban.py +58 -71
- package/agent/hermes_cli/kanban_db.py +391 -14
- package/agent/hermes_cli/kanban_decompose.py +2 -2
- package/agent/hermes_cli/kanban_specify.py +3 -1
- package/agent/hermes_cli/logs.py +2 -0
- package/agent/hermes_cli/main.py +2889 -5287
- package/agent/hermes_cli/managed_scope.py +214 -0
- package/agent/hermes_cli/managed_uv.py +254 -0
- package/agent/hermes_cli/mcp_catalog.py +6 -3
- package/agent/hermes_cli/mcp_config.py +145 -21
- package/agent/hermes_cli/mcp_security.py +96 -0
- package/agent/hermes_cli/mcp_startup.py +32 -3
- package/agent/hermes_cli/memory_providers.py +149 -0
- package/agent/hermes_cli/memory_setup.py +97 -42
- package/agent/hermes_cli/middleware.py +313 -0
- package/agent/hermes_cli/model_catalog.py +31 -0
- package/agent/hermes_cli/model_cost_guard.py +134 -0
- package/agent/hermes_cli/model_normalize.py +2 -1
- package/agent/hermes_cli/model_setup_flows.py +2759 -0
- package/agent/hermes_cli/model_switch.py +242 -27
- package/agent/hermes_cli/models.py +284 -44
- package/agent/hermes_cli/nous_account.py +33 -6
- package/agent/hermes_cli/nous_billing.py +406 -0
- package/agent/hermes_cli/nous_subscription.py +202 -5
- package/agent/hermes_cli/platforms.py +1 -0
- package/agent/hermes_cli/plugins.py +218 -18
- package/agent/hermes_cli/plugins_cmd.py +249 -105
- package/agent/hermes_cli/portal_cli.py +56 -16
- package/agent/hermes_cli/profile_distribution.py +6 -1
- package/agent/hermes_cli/profiles.py +283 -32
- package/agent/hermes_cli/provider_catalog.py +170 -0
- package/agent/hermes_cli/providers.py +4 -1
- package/agent/hermes_cli/pty_bridge.py +53 -4
- package/agent/hermes_cli/runtime_provider.py +216 -34
- package/agent/hermes_cli/secret_prompt.py +4 -4
- package/agent/hermes_cli/secrets_cli.py +24 -0
- package/agent/hermes_cli/send_cmd.py +28 -2
- package/agent/hermes_cli/service_manager.py +166 -19
- package/agent/hermes_cli/session_listing.py +97 -0
- package/agent/hermes_cli/setup.py +158 -94
- package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
- package/agent/hermes_cli/skills_config.py +8 -2
- package/agent/hermes_cli/skills_hub.py +149 -7
- package/agent/hermes_cli/status.py +2 -2
- package/agent/hermes_cli/subcommands/__init__.py +18 -0
- package/agent/hermes_cli/subcommands/_shared.py +29 -0
- package/agent/hermes_cli/subcommands/acp.py +52 -0
- package/agent/hermes_cli/subcommands/auth.py +109 -0
- package/agent/hermes_cli/subcommands/backup.py +38 -0
- package/agent/hermes_cli/subcommands/claw.py +92 -0
- package/agent/hermes_cli/subcommands/config.py +49 -0
- package/agent/hermes_cli/subcommands/cron.py +163 -0
- package/agent/hermes_cli/subcommands/dashboard.py +143 -0
- package/agent/hermes_cli/subcommands/debug.py +77 -0
- package/agent/hermes_cli/subcommands/doctor.py +35 -0
- package/agent/hermes_cli/subcommands/dump.py +28 -0
- package/agent/hermes_cli/subcommands/gateway.py +332 -0
- package/agent/hermes_cli/subcommands/gui.py +63 -0
- package/agent/hermes_cli/subcommands/hooks.py +77 -0
- package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
- package/agent/hermes_cli/subcommands/insights.py +25 -0
- package/agent/hermes_cli/subcommands/login.py +78 -0
- package/agent/hermes_cli/subcommands/logout.py +28 -0
- package/agent/hermes_cli/subcommands/logs.py +78 -0
- package/agent/hermes_cli/subcommands/mcp.py +108 -0
- package/agent/hermes_cli/subcommands/memory.py +53 -0
- package/agent/hermes_cli/subcommands/model.py +72 -0
- package/agent/hermes_cli/subcommands/pairing.py +36 -0
- package/agent/hermes_cli/subcommands/plugins.py +94 -0
- package/agent/hermes_cli/subcommands/postinstall.py +23 -0
- package/agent/hermes_cli/subcommands/profile.py +203 -0
- package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
- package/agent/hermes_cli/subcommands/security.py +62 -0
- package/agent/hermes_cli/subcommands/setup.py +58 -0
- package/agent/hermes_cli/subcommands/skills.py +298 -0
- package/agent/hermes_cli/subcommands/slack.py +60 -0
- package/agent/hermes_cli/subcommands/status.py +28 -0
- package/agent/hermes_cli/subcommands/tools.py +95 -0
- package/agent/hermes_cli/subcommands/uninstall.py +41 -0
- package/agent/hermes_cli/subcommands/update.py +70 -0
- package/agent/hermes_cli/subcommands/version.py +18 -0
- package/agent/hermes_cli/subcommands/webhook.py +76 -0
- package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
- package/agent/hermes_cli/suggestions_cmd.py +153 -0
- package/agent/hermes_cli/telegram_managed_bot.py +358 -0
- package/agent/hermes_cli/tips.py +3 -4
- package/agent/hermes_cli/tools_config.py +155 -28
- package/agent/hermes_cli/uninstall.py +231 -35
- package/agent/hermes_cli/web_server.py +6188 -975
- package/agent/hermes_cli/win_pty_bridge.py +179 -0
- package/agent/hermes_cli/write_approval_commands.py +209 -0
- package/agent/hermes_constants.py +164 -33
- package/agent/hermes_logging.py +74 -2
- package/agent/hermes_state.py +919 -106
- package/agent/hermes_time.py +20 -0
- package/agent/locales/af.yaml +23 -0
- package/agent/locales/de.yaml +23 -0
- package/agent/locales/en.yaml +20 -0
- package/agent/locales/es.yaml +23 -0
- package/agent/locales/fr.yaml +23 -0
- package/agent/locales/ga.yaml +23 -0
- package/agent/locales/hu.yaml +23 -0
- package/agent/locales/it.yaml +23 -0
- package/agent/locales/ja.yaml +23 -0
- package/agent/locales/ko.yaml +23 -0
- package/agent/locales/pt.yaml +23 -0
- package/agent/locales/ru.yaml +23 -0
- package/agent/locales/tr.yaml +23 -0
- package/agent/locales/uk.yaml +23 -0
- package/agent/locales/zh-hant.yaml +23 -0
- package/agent/locales/zh.yaml +23 -0
- package/agent/model_tools.py +204 -40
- package/agent/optional-mcps/clawpump/manifest.yaml +15 -5
- package/agent/optional-mcps/clawpump-stdio/manifest.yaml +14 -4
- package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
- package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
- package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
- package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
- package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
- package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
- package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
- package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
- package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
- package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
- package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
- package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
- package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
- package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
- package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
- package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
- package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
- package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
- package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
- package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
- package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
- package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
- package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
- package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
- package/agent/optional-skills/security/1password/SKILL.md +1 -1
- package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
- package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
- package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
- package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
- package/agent/package-lock.json +4082 -7907
- package/agent/package.json +18 -3
- package/agent/plugins/browser/firecrawl/provider.py +4 -1
- package/agent/plugins/cron/__init__.py +344 -0
- package/agent/plugins/cron/chronos/__init__.py +241 -0
- package/agent/plugins/cron/chronos/_nas_client.py +123 -0
- package/agent/plugins/cron/chronos/plugin.yaml +9 -0
- package/agent/plugins/cron/chronos/verify.py +103 -0
- package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
- package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
- package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
- package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
- package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
- package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
- package/agent/plugins/google_meet/audio_bridge.py +4 -0
- package/agent/plugins/google_meet/meet_bot.py +7 -1
- package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
- package/agent/plugins/image_gen/fal/__init__.py +35 -6
- package/agent/plugins/image_gen/krea/__init__.py +56 -13
- package/agent/plugins/image_gen/openai/__init__.py +122 -24
- package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
- package/agent/plugins/image_gen/xai/__init__.py +92 -12
- package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
- package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
- package/agent/plugins/memory/__init__.py +48 -5
- package/agent/plugins/memory/byterover/__init__.py +1 -0
- package/agent/plugins/memory/hindsight/README.md +1 -1
- package/agent/plugins/memory/hindsight/__init__.py +138 -24
- package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
- package/agent/plugins/memory/honcho/README.md +13 -10
- package/agent/plugins/memory/honcho/cli.py +247 -122
- package/agent/plugins/memory/honcho/client.py +112 -102
- package/agent/plugins/memory/openviking/README.md +12 -1
- package/agent/plugins/memory/openviking/__init__.py +2281 -107
- package/agent/plugins/memory/openviking/plugin.yaml +1 -2
- package/agent/plugins/memory/supermemory/README.md +22 -10
- package/agent/plugins/memory/supermemory/__init__.py +142 -37
- package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
- package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
- package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
- package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
- package/agent/plugins/model-providers/custom/__init__.py +8 -2
- package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
- package/agent/plugins/model-providers/minimax/__init__.py +60 -8
- package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
- package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
- package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
- package/agent/plugins/model-providers/zai/__init__.py +1 -0
- package/agent/plugins/observability/langfuse/__init__.py +147 -14
- package/agent/plugins/observability/nemo_relay/README.md +559 -0
- package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
- package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
- package/agent/plugins/platforms/discord/adapter.py +932 -61
- package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
- package/agent/plugins/platforms/google_chat/adapter.py +9 -3
- package/agent/plugins/platforms/google_chat/oauth.py +1 -1
- package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
- package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
- package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
- package/agent/plugins/platforms/irc/adapter.py +4 -1
- package/agent/plugins/platforms/line/adapter.py +16 -1
- package/agent/plugins/platforms/mattermost/adapter.py +100 -24
- package/agent/plugins/platforms/photon/README.md +179 -0
- package/agent/plugins/platforms/photon/__init__.py +4 -0
- package/agent/plugins/platforms/photon/adapter.py +1586 -0
- package/agent/plugins/platforms/photon/auth.py +1046 -0
- package/agent/plugins/platforms/photon/cli.py +439 -0
- package/agent/plugins/platforms/photon/plugin.yaml +88 -0
- package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
- package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
- package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
- package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
- package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
- package/agent/plugins/platforms/raft/__init__.py +3 -0
- package/agent/plugins/platforms/raft/adapter.py +774 -0
- package/agent/plugins/platforms/raft/plugin.yaml +19 -0
- package/agent/plugins/platforms/simplex/adapter.py +777 -220
- package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
- package/agent/plugins/platforms/teams/adapter.py +175 -5
- package/agent/plugins/plugin_utils.py +135 -0
- package/agent/plugins/video_gen/fal/__init__.py +10 -3
- package/agent/plugins/web/searxng/provider.py +15 -2
- package/agent/plugins/web/xai/provider.py +2 -2
- package/agent/providers/base.py +22 -3
- package/agent/pyproject.toml +115 -21
- package/agent/run_agent.py +733 -39
- package/agent/scripts/build_skills_index.py +51 -19
- package/agent/scripts/check_subprocess_stdin.py +177 -0
- package/agent/scripts/contributor_audit.py +2 -0
- package/agent/scripts/docker_config_migrate.py +67 -0
- package/agent/scripts/install.cmd +3 -3
- package/agent/scripts/install.ps1 +580 -154
- package/agent/scripts/install.sh +402 -185
- package/agent/scripts/lib/node-bootstrap.sh +39 -4
- package/agent/scripts/release.py +183 -0
- package/agent/scripts/run_tests.sh +1 -0
- package/agent/scripts/run_tests_parallel.py +18 -23
- package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
- package/agent/setup.py +59 -0
- package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
- package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
- package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
- package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
- package/agent/skills/clawpump/SKILL.md +53 -5
- package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
- package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
- package/agent/skills/github/github-auth/SKILL.md +2 -2
- package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
- package/agent/skills/github/github-code-review/SKILL.md +2 -2
- package/agent/skills/github/github-issues/SKILL.md +2 -2
- package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
- package/agent/skills/github/github-repo-management/SKILL.md +2 -2
- package/agent/skills/media/gif-search/SKILL.md +1 -1
- package/agent/skills/media/youtube-content/SKILL.md +10 -7
- package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
- package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
- package/agent/skills/productivity/airtable/SKILL.md +2 -2
- package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
- package/agent/skills/productivity/notion/SKILL.md +2 -2
- package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
- package/agent/skills/research/llm-wiki/SKILL.md +1 -1
- package/agent/skills/social-media/xurl/SKILL.md +9 -0
- package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
- package/agent/skills/software-development/plan/SKILL.md +285 -5
- package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
- package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
- package/agent/skills/software-development/spike/SKILL.md +2 -2
- package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
- package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
- package/agent/tools/approval.py +302 -4
- package/agent/tools/async_delegation.py +386 -0
- package/agent/tools/blueprints.py +325 -0
- package/agent/tools/browser_cdp_tool.py +3 -3
- package/agent/tools/browser_tool.py +34 -6
- package/agent/tools/checkpoint_manager.py +31 -1
- package/agent/tools/clarify_tool.py +55 -5
- package/agent/tools/code_execution_tool.py +31 -14
- package/agent/tools/computer_use/cua_backend.py +81 -3
- package/agent/tools/computer_use/tool.py +79 -5
- package/agent/tools/computer_use/vision_routing.py +55 -3
- package/agent/tools/credential_files.py +31 -12
- package/agent/tools/cronjob_tools.py +30 -20
- package/agent/tools/delegate_tool.py +356 -31
- package/agent/tools/env_probe.py +1 -0
- package/agent/tools/environments/docker.py +163 -8
- package/agent/tools/environments/file_sync.py +2 -1
- package/agent/tools/environments/local.py +74 -23
- package/agent/tools/environments/singularity.py +4 -1
- package/agent/tools/environments/ssh.py +78 -11
- package/agent/tools/file_operations.py +277 -41
- package/agent/tools/file_tools.py +166 -28
- package/agent/tools/image_generation_tool.py +515 -29
- package/agent/tools/kanban_tools.py +99 -0
- package/agent/tools/lazy_deps.py +33 -2
- package/agent/tools/mcp_oauth.py +5 -5
- package/agent/tools/mcp_oauth_manager.py +7 -5
- package/agent/tools/mcp_tool.py +840 -33
- package/agent/tools/memory_tool.py +335 -38
- package/agent/tools/osv_check.py +15 -1
- package/agent/tools/process_registry.py +155 -11
- package/agent/tools/read_extract.py +248 -0
- package/agent/tools/read_terminal_tool.py +93 -0
- package/agent/tools/schema_sanitizer.py +38 -0
- package/agent/tools/send_message_tool.py +163 -49
- package/agent/tools/session_search_tool.py +189 -7
- package/agent/tools/skill_manager_tool.py +202 -3
- package/agent/tools/skill_usage.py +52 -4
- package/agent/tools/skills_hub.py +184 -44
- package/agent/tools/skills_sync.py +232 -5
- package/agent/tools/skills_tool.py +125 -11
- package/agent/tools/terminal_tool.py +148 -26
- package/agent/tools/tirith_security.py +2 -0
- package/agent/tools/todo_tool.py +32 -1
- package/agent/tools/transcription_tools.py +13 -5
- package/agent/tools/tts_tool.py +332 -38
- package/agent/tools/url_safety.py +52 -1
- package/agent/tools/vision_tools.py +124 -39
- package/agent/tools/voice_mode.py +4 -3
- package/agent/tools/web_tools.py +45 -15
- package/agent/tools/write_approval.py +493 -0
- package/agent/toolsets.py +34 -10
- package/agent/trajectory_compressor.py +81 -10
- package/agent/tui_gateway/entry.py +43 -6
- package/agent/tui_gateway/server.py +3335 -330
- package/agent/tui_gateway/slash_worker.py +61 -0
- package/agent/tui_gateway/ws.py +67 -9
- package/agent/ui-tui/eslint.config.mjs +0 -4
- package/agent/ui-tui/package.json +6 -6
- package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
- package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
- package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
- package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
- package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
- package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
- package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
- package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
- package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
- package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
- package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
- package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
- package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
- package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
- package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
- package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
- package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
- package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
- package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
- package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
- package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
- package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
- package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
- package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
- package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
- package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
- package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
- package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
- package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
- package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
- package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
- package/agent/ui-tui/src/app/interfaces.ts +64 -1
- package/agent/ui-tui/src/app/overlayStore.ts +18 -2
- package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
- package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
- package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
- package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
- package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
- package/agent/ui-tui/src/app/slash/registry.ts +4 -0
- package/agent/ui-tui/src/app/turnController.ts +145 -2
- package/agent/ui-tui/src/app/uiStore.ts +2 -0
- package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
- package/agent/ui-tui/src/app/useMainApp.ts +54 -8
- package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
- package/agent/ui-tui/src/app/useSubmission.ts +23 -31
- package/agent/ui-tui/src/components/appChrome.tsx +112 -5
- package/agent/ui-tui/src/components/appLayout.tsx +9 -0
- package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
- package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
- package/agent/ui-tui/src/components/branding.tsx +15 -3
- package/agent/ui-tui/src/components/messageLine.tsx +25 -3
- package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
- package/agent/ui-tui/src/components/prompts.tsx +31 -17
- package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
- package/agent/ui-tui/src/components/textInput.tsx +16 -0
- package/agent/ui-tui/src/config/env.ts +12 -0
- package/agent/ui-tui/src/config/limits.ts +13 -0
- package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
- package/agent/ui-tui/src/domain/paths.ts +24 -0
- package/agent/ui-tui/src/domain/slash.ts +40 -0
- package/agent/ui-tui/src/entry.tsx +35 -4
- package/agent/ui-tui/src/gatewayClient.ts +22 -10
- package/agent/ui-tui/src/gatewayTypes.ts +130 -1
- package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
- package/agent/ui-tui/src/lib/memory.test.ts +162 -0
- package/agent/ui-tui/src/lib/memory.ts +60 -1
- package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
- package/agent/ui-tui/src/lib/osc52.ts +1 -1
- package/agent/ui-tui/src/lib/text.test.ts +32 -1
- package/agent/ui-tui/src/lib/text.ts +29 -2
- package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
- package/agent/ui-tui/src/types.ts +5 -0
- package/agent/ui-tui/tsconfig.build.json +0 -1
- package/agent/ui-tui/tsconfig.json +2 -1
- package/agent/utils.py +66 -2
- package/agent/uv.lock +308 -696
- package/agent/web/index.html +2 -2
- package/agent/web/package.json +11 -6
- package/agent/web/public/claw-bg.webp +0 -0
- package/agent/web/public/claw-logo.webp +0 -0
- package/agent/web/src/App.tsx +138 -48
- package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
- package/agent/web/src/components/Backdrop.tsx +15 -0
- package/agent/web/src/components/ChatSessionList.tsx +260 -0
- package/agent/web/src/components/ChatSidebar.tsx +262 -78
- package/agent/web/src/components/ConfirmDialog.tsx +122 -0
- package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
- package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
- package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
- package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
- package/agent/web/src/components/ReasoningPicker.tsx +167 -0
- package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
- package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
- package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
- package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
- package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
- package/agent/web/src/contexts/SystemActions.tsx +6 -8
- package/agent/web/src/contexts/profile-context.ts +19 -0
- package/agent/web/src/contexts/useProfileScope.ts +6 -0
- package/agent/web/src/i18n/af.ts +5 -4
- package/agent/web/src/i18n/de.ts +5 -4
- package/agent/web/src/i18n/en.ts +58 -4
- package/agent/web/src/i18n/es.ts +5 -3
- package/agent/web/src/i18n/fr.ts +5 -3
- package/agent/web/src/i18n/ga.ts +5 -4
- package/agent/web/src/i18n/hu.ts +5 -4
- package/agent/web/src/i18n/it.ts +5 -4
- package/agent/web/src/i18n/ja.ts +5 -4
- package/agent/web/src/i18n/ko.ts +5 -4
- package/agent/web/src/i18n/pt.ts +5 -3
- package/agent/web/src/i18n/ru.ts +5 -4
- package/agent/web/src/i18n/tr.ts +5 -4
- package/agent/web/src/i18n/types.ts +59 -1
- package/agent/web/src/i18n/uk.ts +5 -3
- package/agent/web/src/i18n/zh-hant.ts +5 -4
- package/agent/web/src/i18n/zh.ts +5 -4
- package/agent/web/src/index.css +2 -2
- package/agent/web/src/lib/api.ts +819 -52
- package/agent/web/src/lib/dashboard-flags.ts +16 -7
- package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
- package/agent/web/src/lib/reasoning-effort.ts +36 -0
- package/agent/web/src/lib/session-refresh.test.ts +21 -0
- package/agent/web/src/lib/session-refresh.ts +26 -0
- package/agent/web/src/pages/ChannelsPage.tsx +529 -68
- package/agent/web/src/pages/ChatPage.tsx +249 -56
- package/agent/web/src/pages/ConfigPage.tsx +11 -1
- package/agent/web/src/pages/CronPage.tsx +219 -31
- package/agent/web/src/pages/EnvPage.tsx +25 -6
- package/agent/web/src/pages/FilesPage.tsx +525 -0
- package/agent/web/src/pages/McpPage.tsx +80 -3
- package/agent/web/src/pages/ModelsPage.tsx +97 -12
- package/agent/web/src/pages/PluginsPage.tsx +1 -1
- package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
- package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
- package/agent/web/src/pages/SessionsPage.tsx +144 -13
- package/agent/web/src/pages/SkillsPage.tsx +851 -70
- package/agent/web/src/pages/SystemPage.tsx +340 -4
- package/agent/web/src/pages/WalletPage.tsx +401 -0
- package/agent/web/src/pages/WebhooksPage.tsx +145 -15
- package/agent/web/src/pages/X402Page.tsx +207 -0
- package/agent/web/src/plugins/registry.ts +28 -11
- package/agent/web/src/plugins/sdk.d.ts +160 -0
- package/agent/web/src/themes/context.tsx +112 -5
- package/agent/web/src/themes/fonts.ts +167 -0
- package/agent/web/src/themes/index.ts +7 -0
- package/agent/web/tsconfig.app.json +0 -1
- package/agent/web/vite.config.ts +1 -8
- package/agent/web/vitest.config.ts +16 -0
- package/package.json +1 -1
- package/agent/apps/desktop/package-lock.json +0 -18363
- package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
- package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
- package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
- package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
- package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
- package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
- package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
- package/agent/skills/diagramming/DESCRIPTION.md +0 -3
- package/agent/skills/domain/DESCRIPTION.md +0 -24
- package/agent/skills/gifs/DESCRIPTION.md +0 -3
- package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
- package/agent/skills/mcp/DESCRIPTION.md +0 -3
- package/agent/skills/media/spotify/SKILL.md +0 -135
- package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
- package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
- package/agent/skills/productivity/linear/SKILL.md +0 -380
- package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
- package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
- package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
- package/agent/ui-tui/package-lock.json +0 -7449
- package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
- package/agent/web/package-lock.json +0 -8887
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
- /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
- /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
- /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/gates-taxonomy.md +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import atexit
|
|
2
2
|
import concurrent.futures
|
|
3
|
+
import contextlib
|
|
3
4
|
import contextvars
|
|
4
5
|
import copy
|
|
5
6
|
import inspect
|
|
@@ -16,7 +17,12 @@ from datetime import datetime
|
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any, Optional
|
|
18
19
|
|
|
19
|
-
from hermes_constants import
|
|
20
|
+
from hermes_constants import (
|
|
21
|
+
get_hermes_home,
|
|
22
|
+
get_hermes_home_override,
|
|
23
|
+
reset_hermes_home_override,
|
|
24
|
+
set_hermes_home_override,
|
|
25
|
+
)
|
|
20
26
|
from hermes_cli.env_loader import load_hermes_dotenv
|
|
21
27
|
from utils import is_truthy_value
|
|
22
28
|
from tui_gateway.transport import (
|
|
@@ -125,14 +131,35 @@ _db = None
|
|
|
125
131
|
_db_error: str | None = None
|
|
126
132
|
_stdout_lock = threading.Lock()
|
|
127
133
|
_cfg_lock = threading.Lock()
|
|
134
|
+
_sessions_lock = threading.RLock() # reentrant: _close_session_by_id may run under callers that already hold it
|
|
135
|
+
_prompt_lock = threading.Lock()
|
|
128
136
|
_cfg_cache: dict | None = None
|
|
129
137
|
_cfg_mtime: float | None = None
|
|
130
138
|
_cfg_path = None
|
|
139
|
+
_session_resume_lock = threading.Lock()
|
|
131
140
|
try:
|
|
132
141
|
_slash_timeout = float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S") or "45")
|
|
133
142
|
except (ValueError, TypeError):
|
|
134
143
|
_slash_timeout = 45.0
|
|
135
144
|
_SLASH_WORKER_TIMEOUT_S = max(5.0, _slash_timeout)
|
|
145
|
+
|
|
146
|
+
# When a WebSocket client (the dashboard's embedded-chat tab / desktop app)
|
|
147
|
+
# disconnects, ``tui_gateway.ws`` detaches the transport but intentionally
|
|
148
|
+
# leaves the session parked so a quick reconnect can reattach it (see ws.py).
|
|
149
|
+
# That park is unbounded, though: a browser refresh spins up a brand-new
|
|
150
|
+
# ``session.create`` (new sid + a fresh _SlashWorker via _deferred_build) and
|
|
151
|
+
# never reattaches the OLD sid, so the old session's slash-worker subprocess
|
|
152
|
+
# lingers forever — one leaked python process per refresh (#38591 fallout).
|
|
153
|
+
# After this grace window, an orphaned (transport-detached, not-running) WS
|
|
154
|
+
# session is reaped: its _SlashWorker is closed and the session finalized.
|
|
155
|
+
# Set to 0 to disable (park forever, pre-fix behaviour).
|
|
156
|
+
try:
|
|
157
|
+
_ws_orphan_reap_grace = float(
|
|
158
|
+
os.environ.get("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S") or "20"
|
|
159
|
+
)
|
|
160
|
+
except (ValueError, TypeError):
|
|
161
|
+
_ws_orphan_reap_grace = 20.0
|
|
162
|
+
_WS_ORPHAN_REAP_GRACE_S = max(0.0, _ws_orphan_reap_grace)
|
|
136
163
|
_DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
|
|
137
164
|
_DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
|
|
138
165
|
|
|
@@ -147,8 +174,10 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
|
|
|
147
174
|
# response writes are safe.
|
|
148
175
|
_LONG_HANDLERS = frozenset(
|
|
149
176
|
{
|
|
177
|
+
"billing.step_up",
|
|
150
178
|
"browser.manage",
|
|
151
179
|
"cli.exec",
|
|
180
|
+
"plugins.manage",
|
|
152
181
|
"session.branch",
|
|
153
182
|
"session.compress",
|
|
154
183
|
"session.resume",
|
|
@@ -176,11 +205,27 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
|
|
|
176
205
|
_real_stdout = sys.stdout
|
|
177
206
|
sys.stdout = sys.stderr
|
|
178
207
|
|
|
208
|
+
|
|
209
|
+
class _DropTransport:
|
|
210
|
+
"""Detached WS sink: keep sessions resumable without writing stale frames."""
|
|
211
|
+
|
|
212
|
+
def write(self, obj: dict) -> bool:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
def close(self) -> None:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
179
219
|
# Module-level stdio transport — fallback sink when no transport is bound via
|
|
180
220
|
# contextvar or session. Stream resolved through a lambda so runtime monkey-
|
|
181
221
|
# patches of `_real_stdout` (used extensively in tests) still land correctly.
|
|
182
222
|
_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock)
|
|
183
223
|
|
|
224
|
+
# Detached websocket sessions use a drop sink instead of stdio. Desktop embeds
|
|
225
|
+
# the gateway in-process and captures stdout into logs, so stale JSON-RPC frames
|
|
226
|
+
# must not fall through there while the session waits for resume or reap.
|
|
227
|
+
_detached_ws_transport = _DropTransport()
|
|
228
|
+
|
|
184
229
|
|
|
185
230
|
class _SlashWorker:
|
|
186
231
|
"""Persistent HermesCLI subprocess for slash commands."""
|
|
@@ -201,6 +246,7 @@ class _SlashWorker:
|
|
|
201
246
|
if model:
|
|
202
247
|
argv += ["--model", model]
|
|
203
248
|
|
|
249
|
+
self._closed = False
|
|
204
250
|
self.proc = subprocess.Popen(
|
|
205
251
|
argv,
|
|
206
252
|
stdin=subprocess.PIPE,
|
|
@@ -255,15 +301,33 @@ class _SlashWorker:
|
|
|
255
301
|
)
|
|
256
302
|
|
|
257
303
|
def close(self):
|
|
304
|
+
if getattr(self, "_closed", False):
|
|
305
|
+
return
|
|
306
|
+
self._closed = True
|
|
307
|
+
proc = self.proc
|
|
258
308
|
try:
|
|
259
|
-
if
|
|
260
|
-
|
|
261
|
-
|
|
309
|
+
if proc.poll() is None:
|
|
310
|
+
proc.terminate()
|
|
311
|
+
try:
|
|
312
|
+
proc.wait(timeout=1)
|
|
313
|
+
except Exception:
|
|
314
|
+
proc.kill()
|
|
315
|
+
try:
|
|
316
|
+
proc.wait(timeout=1) # reap the zombie SIGKILL leaves behind
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
262
319
|
except Exception:
|
|
263
320
|
try:
|
|
264
|
-
|
|
321
|
+
proc.kill()
|
|
322
|
+
proc.wait(timeout=1)
|
|
265
323
|
except Exception:
|
|
266
324
|
pass
|
|
325
|
+
finally:
|
|
326
|
+
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
327
|
+
try:
|
|
328
|
+
stream.close()
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
267
331
|
|
|
268
332
|
|
|
269
333
|
def _load_busy_input_mode() -> str:
|
|
@@ -284,11 +348,44 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
|
|
|
284
348
|
pass
|
|
285
349
|
|
|
286
350
|
|
|
351
|
+
def _claim_active_session_slot(
|
|
352
|
+
session_key: str,
|
|
353
|
+
*,
|
|
354
|
+
live_session_id: str,
|
|
355
|
+
surface: str = "tui",
|
|
356
|
+
) -> tuple[Any, str | None]:
|
|
357
|
+
try:
|
|
358
|
+
from hermes_cli.active_sessions import try_acquire_active_session
|
|
359
|
+
|
|
360
|
+
return try_acquire_active_session(
|
|
361
|
+
session_id=session_key,
|
|
362
|
+
surface=surface,
|
|
363
|
+
config=_load_cfg(),
|
|
364
|
+
metadata={"live_session_id": live_session_id},
|
|
365
|
+
)
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
logger.warning("Failed to claim active session slot: %s", exc)
|
|
368
|
+
return None, None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _release_active_session_slot(session: dict | None) -> None:
|
|
372
|
+
if not session:
|
|
373
|
+
return
|
|
374
|
+
lease = session.pop("active_session_lease", None)
|
|
375
|
+
if lease is None:
|
|
376
|
+
return
|
|
377
|
+
try:
|
|
378
|
+
lease.release()
|
|
379
|
+
except Exception:
|
|
380
|
+
logger.debug("Failed to release active session slot", exc_info=True)
|
|
381
|
+
|
|
382
|
+
|
|
287
383
|
def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None:
|
|
288
384
|
"""Best-effort finalize hook + memory commit for a session."""
|
|
289
385
|
if not session or session.get("_finalized"):
|
|
290
386
|
return
|
|
291
387
|
session["_finalized"] = True
|
|
388
|
+
_release_active_session_slot(session)
|
|
292
389
|
stop_event = session.get("_notif_stop")
|
|
293
390
|
if stop_event is not None:
|
|
294
391
|
stop_event.set()
|
|
@@ -322,19 +419,226 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
|
|
|
322
419
|
except Exception:
|
|
323
420
|
pass
|
|
324
421
|
|
|
422
|
+
# Close the slash-worker subprocess as part of finalize itself, not just
|
|
423
|
+
# in the callers. Defense-in-depth: every session-end path goes through
|
|
424
|
+
# _finalize_session (it's the single ``_finalized``-guarded chokepoint), so
|
|
425
|
+
# folding worker cleanup in here means a future code path that calls
|
|
426
|
+
# _finalize_session directly — without the surrounding _teardown_session /
|
|
427
|
+
# _shutdown_sessions worker.close() — can't reintroduce the #38095 leak.
|
|
428
|
+
# Idempotent: _SlashWorker.close() is poll()-guarded, so the explicit
|
|
429
|
+
# close() still in those callers is harmless.
|
|
430
|
+
try:
|
|
431
|
+
worker = session.get("slash_worker")
|
|
432
|
+
if worker:
|
|
433
|
+
worker.close()
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _teardown_session(session: dict | None, *, end_reason: str = "tui_close") -> None:
|
|
439
|
+
"""Fully tear down a session: finalize, unregister, close agent + worker.
|
|
440
|
+
|
|
441
|
+
Shared by ``session.close`` and the orphaned-WS-session reaper. The
|
|
442
|
+
slash-worker subprocess is closed inside ``_finalize_session`` (the single
|
|
443
|
+
finalize chokepoint); this still unregisters the approval notifier and
|
|
444
|
+
closes the in-process agent. Idempotent: the ``_finalized`` guard in
|
|
445
|
+
``_finalize_session`` and the ``poll()`` guard in ``_SlashWorker.close``
|
|
446
|
+
make repeat calls harmless.
|
|
447
|
+
"""
|
|
448
|
+
if not session:
|
|
449
|
+
return
|
|
450
|
+
_finalize_session(session, end_reason=end_reason)
|
|
451
|
+
try:
|
|
452
|
+
from tools.approval import unregister_gateway_notify
|
|
453
|
+
|
|
454
|
+
if key := session.get("session_key"):
|
|
455
|
+
unregister_gateway_notify(key)
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
try:
|
|
459
|
+
agent = session.get("agent")
|
|
460
|
+
if agent is not None and hasattr(agent, "close"):
|
|
461
|
+
agent.close()
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
# NOTE: the slash-worker is closed inside _finalize_session (the single
|
|
465
|
+
# _finalized-guarded chokepoint that main folded it into), exactly once.
|
|
466
|
+
# We deliberately do NOT re-close it here — _teardown_session's job beyond
|
|
467
|
+
# finalize is unregistering the notifier and closing the in-process agent.
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _attach_worker(sid: str, session: dict, worker) -> None:
|
|
471
|
+
"""Store worker on session iff sid still maps to it, else close it — a
|
|
472
|
+
concurrent teardown already popped the session and would orphan the
|
|
473
|
+
worker. Closes the create/close race at every slash-worker spawn site."""
|
|
474
|
+
with _sessions_lock:
|
|
475
|
+
if _sessions.get(sid) is session:
|
|
476
|
+
session["slash_worker"] = worker
|
|
477
|
+
return
|
|
478
|
+
worker.close()
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _close_session_by_id(sid: str, *, end_reason: str = "tui_close") -> bool:
|
|
482
|
+
"""Single idempotent teardown for one session: pop it under the sessions
|
|
483
|
+
lock, then finalize, unregister notify, close agent + slash worker via the
|
|
484
|
+
shared ``_teardown_session`` path. Returns True iff it closed a live
|
|
485
|
+
session. The ``_finalized`` / worker ``_closed`` guards make concurrent or
|
|
486
|
+
repeat calls (e.g. session.close racing the WS-orphan reaper) harmless."""
|
|
487
|
+
with _sessions_lock:
|
|
488
|
+
session = _sessions.pop(sid, None)
|
|
489
|
+
if session is None:
|
|
490
|
+
return False
|
|
491
|
+
_teardown_session(session, end_reason=end_reason)
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _ws_session_is_orphaned(session: dict | None) -> bool:
|
|
497
|
+
"""True if a WS session has no live transport and no in-flight turn.
|
|
498
|
+
|
|
499
|
+
After ``handle_ws`` detaches a disconnected client it points the session at
|
|
500
|
+
``_detached_ws_transport``. A session left on that transport (and not
|
|
501
|
+
mid-turn) is genuinely orphaned and safe to reap.
|
|
502
|
+
"""
|
|
503
|
+
if not session or session.get("_finalized"):
|
|
504
|
+
return False
|
|
505
|
+
if session.get("running"):
|
|
506
|
+
return False
|
|
507
|
+
return session.get("transport") is _detached_ws_transport
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _schedule_ws_orphan_reap(sid: str) -> None:
|
|
511
|
+
"""After a grace window, reap session ``sid`` iff it's still orphaned.
|
|
512
|
+
|
|
513
|
+
Called from the WS-disconnect path. The grace window lets a transient
|
|
514
|
+
reconnect (or a ``session.resume`` that reattaches the transport) cancel
|
|
515
|
+
the reap by re-binding a live transport. Disabled when the grace is 0.
|
|
516
|
+
"""
|
|
517
|
+
if _WS_ORPHAN_REAP_GRACE_S <= 0:
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
def _reap() -> None:
|
|
521
|
+
# Serialize the orphan re-check against session.resume (which re-binds a
|
|
522
|
+
# live transport under _session_resume_lock and would make this session
|
|
523
|
+
# non-orphaned). The actual pop + teardown then goes through the shared
|
|
524
|
+
# _close_session_by_id funnel so the dict mutation happens under
|
|
525
|
+
# _sessions_lock — consistent with every other _sessions mutator
|
|
526
|
+
# (#39591: _reap previously popped under _session_resume_lock, giving no
|
|
527
|
+
# mutual exclusion against _init_session / _close_session_by_id, which
|
|
528
|
+
# guard with _sessions_lock). _sessions_lock is an RLock and the global
|
|
529
|
+
# ordering is always resume_lock -> sessions_lock, so nesting is safe.
|
|
530
|
+
with _session_resume_lock:
|
|
531
|
+
if not _ws_session_is_orphaned(_sessions.get(sid)):
|
|
532
|
+
return
|
|
533
|
+
_close_session_by_id(sid, end_reason="ws_orphan_reap")
|
|
534
|
+
|
|
535
|
+
timer = threading.Timer(_WS_ORPHAN_REAP_GRACE_S, _reap)
|
|
536
|
+
timer.daemon = True
|
|
537
|
+
timer.start()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _close_sessions_for_transport(
|
|
541
|
+
transport, *, end_reason: str = "ws_disconnect"
|
|
542
|
+
) -> tuple[int, int]:
|
|
543
|
+
"""On transport disconnect, reap the sessions that opted into
|
|
544
|
+
close_on_disconnect (sidecar/dashboard) immediately via the unified
|
|
545
|
+
``_close_session_by_id`` path, and re-point the rest back to stdio so later
|
|
546
|
+
emits don't hit a dead socket.
|
|
547
|
+
|
|
548
|
+
Non-flagged detached sessions are handed to the grace-windowed WS-orphan
|
|
549
|
+
reaper (``_schedule_ws_orphan_reap``): a quick reconnect / session.resume
|
|
550
|
+
that re-binds a live transport cancels the reap, otherwise the orphan is
|
|
551
|
+
torn down through the same idempotent ``_teardown_session`` path. This is
|
|
552
|
+
the single WS-disconnect teardown entry point — there is no second
|
|
553
|
+
independent reap loop in ``handle_ws``.
|
|
554
|
+
|
|
555
|
+
Returns ``(reaped, detached)`` counts for disconnect-path observability."""
|
|
556
|
+
with _sessions_lock:
|
|
557
|
+
owned = [(sid, s) for sid, s in _sessions.items() if s.get("transport") is transport]
|
|
558
|
+
reaped = 0
|
|
559
|
+
detached = 0
|
|
560
|
+
for sid, session in owned:
|
|
561
|
+
if session.get("close_on_disconnect"):
|
|
562
|
+
_close_session_by_id(sid, end_reason=end_reason)
|
|
563
|
+
reaped += 1
|
|
564
|
+
else:
|
|
565
|
+
# Point detached sessions at the drop sentinel (NOT real stdio) so
|
|
566
|
+
# _ws_session_is_orphaned recognizes them and the grace-reap can
|
|
567
|
+
# actually fire; a standalone `hermes --tui` keeps real _stdio.
|
|
568
|
+
session["transport"] = _detached_ws_transport
|
|
569
|
+
detached += 1
|
|
570
|
+
try:
|
|
571
|
+
_schedule_ws_orphan_reap(sid)
|
|
572
|
+
except Exception:
|
|
573
|
+
pass
|
|
574
|
+
return reaped, detached
|
|
575
|
+
|
|
325
576
|
|
|
326
577
|
def _shutdown_sessions() -> None:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
578
|
+
with _sessions_lock:
|
|
579
|
+
sids = list(_sessions)
|
|
580
|
+
for sid in sids:
|
|
581
|
+
_close_session_by_id(sid, end_reason="tui_shutdown")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# Last-resort net for any disconnect path that slips past the WS finally. TTL is
|
|
585
|
+
# hours-scale because last_active freezes during a long turn and on passive
|
|
586
|
+
# viewing — running/pending/starting/live-transport are hard exemptions instead.
|
|
587
|
+
try:
|
|
588
|
+
_SESSION_TTL_S = float(os.environ.get("HERMES_TUI_SESSION_TTL_S") or 6 * 3600)
|
|
589
|
+
except (TypeError, ValueError):
|
|
590
|
+
_SESSION_TTL_S = float(6 * 3600)
|
|
591
|
+
_SESSION_TTL_S = max(0.0, _SESSION_TTL_S)
|
|
592
|
+
_REAPER_SCAN_S = 300.0
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _transport_is_dead(transport) -> bool:
|
|
596
|
+
# _detached_ws_transport is the post-WS-disconnect drop sentinel; a session
|
|
597
|
+
# parked on it has no live client. _stdio_transport is the REAL transport
|
|
598
|
+
# for a standalone `hermes --tui`, so it must NOT count as dead here (doing
|
|
599
|
+
# so let the idle reaper evict healthy standalone TUI sessions).
|
|
600
|
+
if transport is _detached_ws_transport:
|
|
601
|
+
return True
|
|
602
|
+
return getattr(transport, "_closed", None) is True
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _session_is_evictable(sid: str, session: dict, now: float) -> bool:
|
|
606
|
+
if session.get("running") or _session_pending_kind(sid):
|
|
607
|
+
return False
|
|
608
|
+
ready = session.get("agent_ready")
|
|
609
|
+
# Lazy watch sessions (subagent spectator windows) never start a build,
|
|
610
|
+
# so their forever-unset agent_ready must not make them immortal.
|
|
611
|
+
if ready is not None and not ready.is_set() and not session.get("lazy"):
|
|
612
|
+
return False
|
|
613
|
+
if not _transport_is_dead(session.get("transport")):
|
|
614
|
+
return False
|
|
615
|
+
last_active = float(session.get("last_active") or 0.0)
|
|
616
|
+
created_at = float(session.get("created_at") or 0.0)
|
|
617
|
+
return (now - last_active) > _SESSION_TTL_S and (now - created_at) > _SESSION_TTL_S
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _reap_idle_sessions() -> None:
|
|
621
|
+
now = time.time()
|
|
622
|
+
with _sessions_lock:
|
|
623
|
+
victims = [sid for sid, s in _sessions.items() if _session_is_evictable(sid, s, now)]
|
|
624
|
+
for sid in victims:
|
|
625
|
+
_close_session_by_id(sid, end_reason="idle_timeout")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _start_idle_reaper() -> None:
|
|
629
|
+
def _loop():
|
|
630
|
+
while True:
|
|
631
|
+
time.sleep(_REAPER_SCAN_S)
|
|
632
|
+
try:
|
|
633
|
+
_reap_idle_sessions()
|
|
634
|
+
except Exception:
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
threading.Thread(target=_loop, daemon=True).start()
|
|
335
638
|
|
|
336
639
|
|
|
337
640
|
atexit.register(_shutdown_sessions)
|
|
641
|
+
_start_idle_reaper()
|
|
338
642
|
|
|
339
643
|
|
|
340
644
|
# ── Plumbing ──────────────────────────────────────────────────────────
|
|
@@ -363,6 +667,65 @@ def _db_unavailable_error(rid, *, code: int):
|
|
|
363
667
|
return _err(rid, code, f"state.db unavailable: {detail}")
|
|
364
668
|
|
|
365
669
|
|
|
670
|
+
# ── per-session profile scoping (global remote mode) ───────────────────────────
|
|
671
|
+
# One dashboard normally serves its launch profile. But the desktop's app-global
|
|
672
|
+
# remote mode points every profile at this single backend, so resume/prompt must
|
|
673
|
+
# be able to act on ANOTHER local profile's state.db + home. The desktop passes
|
|
674
|
+
# ``profile`` on those calls; we open that profile's db and bind its HERMES_HOME
|
|
675
|
+
# (a ContextVar override) for the duration of the call so config/skills/model and
|
|
676
|
+
# message persistence all resolve to the right profile. Omitted/own profile → the
|
|
677
|
+
# launch profile (unchanged for single-profile and per-profile-remote setups).
|
|
678
|
+
def _profile_home(profile: str | None) -> Path | None:
|
|
679
|
+
"""Resolve a named profile's home on THIS host, or None for the launch profile."""
|
|
680
|
+
name = (profile or "").strip()
|
|
681
|
+
if not name:
|
|
682
|
+
return None
|
|
683
|
+
try:
|
|
684
|
+
from hermes_cli import profiles as profiles_mod
|
|
685
|
+
|
|
686
|
+
home = Path(profiles_mod.get_profile_dir(name))
|
|
687
|
+
except Exception:
|
|
688
|
+
return None
|
|
689
|
+
# Already the launch profile? No override needed.
|
|
690
|
+
if home.resolve() == Path(_hermes_home).resolve():
|
|
691
|
+
return None
|
|
692
|
+
return home if (home / "state.db").exists() or home.exists() else None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# Placeholder ``terminal.cwd`` values that don't name a real directory — the
|
|
696
|
+
# gateway resolves these to the home dir at runtime, so they must NOT be treated
|
|
697
|
+
# as an explicit workspace (mirrors gateway/run.py's config bridge).
|
|
698
|
+
_CWD_PLACEHOLDERS = {".", "auto", "cwd"}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _profile_configured_cwd(profile_home: Path | None) -> str | None:
|
|
702
|
+
"""Resolve a non-launch profile's ``terminal.cwd`` from its own config.yaml.
|
|
703
|
+
|
|
704
|
+
The desktop's app-global remote mode serves every profile from one backend,
|
|
705
|
+
so the process-global ``TERMINAL_CWD`` belongs to the *launch* profile. A new
|
|
706
|
+
session bound to another profile must take its workspace from THAT profile's
|
|
707
|
+
config, not the stale env var (issue #40334). Returns an absolute, existing
|
|
708
|
+
directory, or None for placeholders / missing / invalid paths.
|
|
709
|
+
"""
|
|
710
|
+
if profile_home is None:
|
|
711
|
+
return None
|
|
712
|
+
try:
|
|
713
|
+
import yaml
|
|
714
|
+
|
|
715
|
+
p = Path(profile_home) / "config.yaml"
|
|
716
|
+
if not p.exists():
|
|
717
|
+
return None
|
|
718
|
+
with open(p, encoding="utf-8") as f:
|
|
719
|
+
data = yaml.safe_load(f) or {}
|
|
720
|
+
raw = str((data.get("terminal") or {}).get("cwd") or "").strip()
|
|
721
|
+
if not raw or raw in _CWD_PLACEHOLDERS:
|
|
722
|
+
return None
|
|
723
|
+
resolved = os.path.abspath(os.path.expanduser(raw))
|
|
724
|
+
return resolved if os.path.isdir(resolved) else None
|
|
725
|
+
except Exception:
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|
|
366
729
|
def write_json(obj: dict) -> bool:
|
|
367
730
|
"""Emit one JSON frame. Routes via the most-specific transport available.
|
|
368
731
|
|
|
@@ -395,11 +758,16 @@ def _status_update(sid: str, kind: str, text: str | None = None):
|
|
|
395
758
|
body = (text if text is not None else kind).strip()
|
|
396
759
|
if not body:
|
|
397
760
|
return
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
761
|
+
out_kind = kind if text is not None else "status"
|
|
762
|
+
# Auto-compaction reaches us as a generic "lifecycle" status. Re-tag it so
|
|
763
|
+
# drivers (desktop app) can show an explicit "Summarizing…" indicator —
|
|
764
|
+
# otherwise a mid-turn compaction looks like the transcript reset itself.
|
|
765
|
+
if out_kind == "lifecycle":
|
|
766
|
+
from agent.conversation_compression import COMPACTION_STATUS_MARKER
|
|
767
|
+
|
|
768
|
+
if COMPACTION_STATUS_MARKER in body:
|
|
769
|
+
out_kind = "compacting"
|
|
770
|
+
_emit("status.update", sid, {"kind": out_kind, "text": body})
|
|
403
771
|
|
|
404
772
|
|
|
405
773
|
def _estimate_image_tokens(width: int, height: int) -> int:
|
|
@@ -537,35 +905,79 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|
|
537
905
|
ready = session.get("agent_ready")
|
|
538
906
|
if ready is None:
|
|
539
907
|
return
|
|
908
|
+
# A lazy watch session spectating an in-flight child must stay lazy so the
|
|
909
|
+
# subagent live-mirror keeps flowing. Incidental RPCs (session.info, model
|
|
910
|
+
# metadata, etc.) resolve through _sess(), which would otherwise upgrade it
|
|
911
|
+
# to a full agent mid-stream and silently kill the mirror (the mirror bails
|
|
912
|
+
# once agent is set). Once the child completes, the guard lifts and the next
|
|
913
|
+
# prompt/RPC builds the agent normally so the user can talk to the session.
|
|
914
|
+
if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")):
|
|
915
|
+
return
|
|
540
916
|
lock = session.setdefault("agent_build_lock", threading.Lock())
|
|
541
917
|
with lock:
|
|
542
918
|
if ready.is_set() or session.get("agent_build_started"):
|
|
543
919
|
return
|
|
544
920
|
session["agent_build_started"] = True
|
|
921
|
+
# An upgrading lazy session is now genuinely mid-construction — restore
|
|
922
|
+
# its "still starting" eviction exemption.
|
|
923
|
+
session.pop("lazy", None)
|
|
545
924
|
key = session["session_key"]
|
|
546
925
|
|
|
547
926
|
def _build() -> None:
|
|
548
|
-
|
|
927
|
+
with _sessions_lock:
|
|
928
|
+
current = _sessions.get(sid)
|
|
549
929
|
if current is None:
|
|
550
930
|
ready.set()
|
|
551
931
|
return
|
|
552
932
|
|
|
553
933
|
worker = None
|
|
554
934
|
notify_registered = False
|
|
935
|
+
home_token = None
|
|
936
|
+
profile_home = current.get("profile_home")
|
|
555
937
|
try:
|
|
556
938
|
tokens = _set_session_context(key)
|
|
939
|
+
# Build against the session's profile (global-remote): bind its
|
|
940
|
+
# HERMES_HOME so config/skills/model resolve to it, and hand the
|
|
941
|
+
# agent that profile's db so turns persist to the right state.db.
|
|
942
|
+
session_db = None
|
|
943
|
+
if profile_home:
|
|
944
|
+
home_token = set_hermes_home_override(profile_home)
|
|
945
|
+
try:
|
|
946
|
+
from hermes_state import SessionDB
|
|
947
|
+
|
|
948
|
+
session_db = SessionDB(db_path=Path(profile_home) / "state.db")
|
|
949
|
+
except Exception:
|
|
950
|
+
session_db = None
|
|
557
951
|
try:
|
|
558
|
-
|
|
952
|
+
# Lazy-resumed (watch) sessions carry the stored conversation
|
|
953
|
+
# id — pass it through so the upgrade continues that session
|
|
954
|
+
# instead of starting a fresh one under the same key.
|
|
955
|
+
kw = {"session_db": session_db}
|
|
956
|
+
if resume_sid := current.get("resume_session_id"):
|
|
957
|
+
kw["session_id"] = resume_sid
|
|
958
|
+
# Model/effort/fast the desktop picked for a brand-new chat ride
|
|
959
|
+
# in as per-session overrides so the first build uses them
|
|
960
|
+
# directly (no global config, no build-then-switch).
|
|
961
|
+
if override := current.get("model_override"):
|
|
962
|
+
kw["model_override"] = override
|
|
963
|
+
if (reasoning := current.get("create_reasoning_override")) is not None:
|
|
964
|
+
kw["reasoning_config_override"] = reasoning
|
|
965
|
+
if (tier := current.get("create_service_tier_override")) is not None:
|
|
966
|
+
kw["service_tier_override"] = tier
|
|
967
|
+
agent = _make_agent(sid, key, **kw)
|
|
559
968
|
finally:
|
|
560
969
|
_clear_session_context(tokens)
|
|
561
970
|
|
|
562
971
|
# Session DB row deferred to first run_conversation() call.
|
|
563
972
|
# pending_title applied post-first-message (see cli.exec handler).
|
|
564
973
|
current["agent"] = agent
|
|
974
|
+
# Baseline for the per-turn config sync; the profile home
|
|
975
|
+
# override is still active here.
|
|
976
|
+
current["config_model_seen"] = _config_model_target()
|
|
565
977
|
|
|
566
978
|
try:
|
|
567
979
|
worker = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
|
|
568
|
-
current
|
|
980
|
+
_attach_worker(sid, current, worker)
|
|
569
981
|
except Exception:
|
|
570
982
|
pass
|
|
571
983
|
|
|
@@ -584,7 +996,18 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|
|
584
996
|
pass
|
|
585
997
|
|
|
586
998
|
_wire_callbacks(sid)
|
|
587
|
-
|
|
999
|
+
# Hydrate credits notices at session OPEN (not just on the first
|
|
1000
|
+
# message), so depletion / usage-band warnings show at "ready". Runs
|
|
1001
|
+
# off the build thread, after the notice_callback is wired. Fail-open.
|
|
1002
|
+
try:
|
|
1003
|
+
from agent.credits_tracker import seed_credits_at_session_start
|
|
1004
|
+
|
|
1005
|
+
seed_credits_at_session_start(agent)
|
|
1006
|
+
except Exception:
|
|
1007
|
+
pass
|
|
1008
|
+
with _sessions_lock:
|
|
1009
|
+
if sid in _sessions:
|
|
1010
|
+
_sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
|
|
588
1011
|
_notify_session_boundary("on_session_reset", key)
|
|
589
1012
|
|
|
590
1013
|
info = _session_info(agent, current)
|
|
@@ -593,23 +1016,29 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|
|
593
1016
|
info["config_warning"] = cfg_warn
|
|
594
1017
|
logger.warning(cfg_warn)
|
|
595
1018
|
_emit("session.info", sid, info)
|
|
1019
|
+
# If MCP discovery is still in flight (a server slower than the
|
|
1020
|
+
# bounded wait_for_mcp_discovery join in _make_agent), the agent
|
|
1021
|
+
# was built without those tools. Catch up once they land — see
|
|
1022
|
+
# _schedule_mcp_late_refresh. Cache-safe (pre-first-turn only).
|
|
1023
|
+
_schedule_mcp_late_refresh(sid, agent)
|
|
596
1024
|
except Exception as e:
|
|
597
1025
|
current["agent_error"] = str(e)
|
|
598
1026
|
_emit("error", sid, {"message": f"agent init failed: {e}"})
|
|
599
1027
|
finally:
|
|
600
|
-
if
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1028
|
+
if home_token is not None:
|
|
1029
|
+
reset_hermes_home_override(home_token)
|
|
1030
|
+
# _attach_worker already closed the worker if this session was
|
|
1031
|
+
# reaped mid-build; only the late notify registration can still
|
|
1032
|
+
# leak (session.close unregistered before _build registered it).
|
|
1033
|
+
with _sessions_lock:
|
|
1034
|
+
replaced = _sessions.get(sid) is not current
|
|
1035
|
+
if replaced and notify_registered:
|
|
1036
|
+
try:
|
|
1037
|
+
from tools.approval import unregister_gateway_notify
|
|
609
1038
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1039
|
+
unregister_gateway_notify(key)
|
|
1040
|
+
except Exception:
|
|
1041
|
+
pass
|
|
613
1042
|
ready.set()
|
|
614
1043
|
|
|
615
1044
|
threading.Thread(target=_build, daemon=True).start()
|
|
@@ -643,9 +1072,13 @@ def _normalize_completion_path(path_part: str) -> str:
|
|
|
643
1072
|
|
|
644
1073
|
|
|
645
1074
|
def _completion_cwd(params: dict | None = None) -> str:
|
|
1075
|
+
params = params or {}
|
|
646
1076
|
raw = (
|
|
647
|
-
|
|
648
|
-
or _sessions.get(
|
|
1077
|
+
params.get("cwd")
|
|
1078
|
+
or _sessions.get(params.get("session_id") or "", {}).get("cwd")
|
|
1079
|
+
# A session bound to another profile resolves its workspace from THAT
|
|
1080
|
+
# profile's config before falling back to the launch profile's env var.
|
|
1081
|
+
or _profile_configured_cwd(_profile_home(params.get("profile")))
|
|
649
1082
|
or os.environ.get("TERMINAL_CWD")
|
|
650
1083
|
or os.getcwd()
|
|
651
1084
|
)
|
|
@@ -658,6 +1091,30 @@ def _completion_cwd(params: dict | None = None) -> str:
|
|
|
658
1091
|
return os.getcwd()
|
|
659
1092
|
|
|
660
1093
|
|
|
1094
|
+
def _terminal_task_cwd(session: dict | None) -> str:
|
|
1095
|
+
"""Return the cwd that terminal_tool should use for this TUI session.
|
|
1096
|
+
|
|
1097
|
+
``_completion_cwd`` validates paths on the host so file completion does not
|
|
1098
|
+
point at nonsense. Non-local terminal backends are different: their cwd is
|
|
1099
|
+
inside the target environment, so an SSH path like /home/user/workspace may
|
|
1100
|
+
not exist on the local macOS host but is still the correct execution cwd.
|
|
1101
|
+
"""
|
|
1102
|
+
backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
|
|
1103
|
+
if backend and backend != "local":
|
|
1104
|
+
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
|
1105
|
+
if not raw:
|
|
1106
|
+
try:
|
|
1107
|
+
terminal_cfg = _load_cfg().get("terminal", {})
|
|
1108
|
+
if isinstance(terminal_cfg, dict):
|
|
1109
|
+
raw = str(terminal_cfg.get("cwd") or "").strip()
|
|
1110
|
+
except Exception:
|
|
1111
|
+
raw = ""
|
|
1112
|
+
if raw and raw not in {".", "auto", "cwd"}:
|
|
1113
|
+
return raw
|
|
1114
|
+
|
|
1115
|
+
return _session_cwd(session)
|
|
1116
|
+
|
|
1117
|
+
|
|
661
1118
|
def _git_branch_for_cwd(cwd: str) -> str:
|
|
662
1119
|
try:
|
|
663
1120
|
result = subprocess.run(
|
|
@@ -666,6 +1123,7 @@ def _git_branch_for_cwd(cwd: str) -> str:
|
|
|
666
1123
|
text=True,
|
|
667
1124
|
timeout=1.5,
|
|
668
1125
|
check=False,
|
|
1126
|
+
stdin=subprocess.DEVNULL,
|
|
669
1127
|
)
|
|
670
1128
|
if result.returncode == 0:
|
|
671
1129
|
branch = result.stdout.strip()
|
|
@@ -677,6 +1135,7 @@ def _git_branch_for_cwd(cwd: str) -> str:
|
|
|
677
1135
|
text=True,
|
|
678
1136
|
timeout=1.5,
|
|
679
1137
|
check=False,
|
|
1138
|
+
stdin=subprocess.DEVNULL,
|
|
680
1139
|
)
|
|
681
1140
|
return head.stdout.strip() if head.returncode == 0 else ""
|
|
682
1141
|
except Exception:
|
|
@@ -696,7 +1155,7 @@ def _register_session_cwd(session: dict | None) -> None:
|
|
|
696
1155
|
from tools.terminal_tool import register_task_env_overrides
|
|
697
1156
|
|
|
698
1157
|
register_task_env_overrides(
|
|
699
|
-
session["session_key"], {"cwd":
|
|
1158
|
+
session["session_key"], {"cwd": _terminal_task_cwd(session)}
|
|
700
1159
|
)
|
|
701
1160
|
except Exception:
|
|
702
1161
|
pass
|
|
@@ -720,18 +1179,115 @@ def _ensure_session_db_row(session: dict) -> None:
|
|
|
720
1179
|
key = session.get("session_key")
|
|
721
1180
|
if not key:
|
|
722
1181
|
return
|
|
723
|
-
db
|
|
1182
|
+
# Persist into the session's own profile db (global remote mode), not the
|
|
1183
|
+
# launch profile's — otherwise the row lands in the wrong state.db, the
|
|
1184
|
+
# unified list mis-tags it, and resume 404s ("session not found").
|
|
1185
|
+
profile_home = session.get("profile_home")
|
|
1186
|
+
if profile_home:
|
|
1187
|
+
from hermes_state import SessionDB
|
|
1188
|
+
|
|
1189
|
+
try:
|
|
1190
|
+
db = SessionDB(db_path=Path(profile_home) / "state.db")
|
|
1191
|
+
except Exception:
|
|
1192
|
+
logger.debug("failed to open profile db for session row", exc_info=True)
|
|
1193
|
+
return
|
|
1194
|
+
close_db = True
|
|
1195
|
+
else:
|
|
1196
|
+
db = _get_db()
|
|
1197
|
+
close_db = False
|
|
724
1198
|
if db is None:
|
|
725
1199
|
return
|
|
1200
|
+
# The session's own model/effort/fast pick — the composer override shipped on
|
|
1201
|
+
# session.create, or a restored /model switch — must own the row's model +
|
|
1202
|
+
# model_config. The agent isn't built yet at first prompt.submit, so derive
|
|
1203
|
+
# the row from the live override dict; fall back to the global resolved model
|
|
1204
|
+
# only when this chat made no explicit pick. Writing the global default here
|
|
1205
|
+
# used to win the INSERT-OR-IGNORE race against the agent's own correct
|
|
1206
|
+
# lazy-create, so a reconnect/resume rebuilt from the global model and
|
|
1207
|
+
# silently reverted the chat (e.g. picked gpt-5.5, reconnect snapped back to
|
|
1208
|
+
# the profile default). model_config carries provider/reasoning/service_tier
|
|
1209
|
+
# so resume restores effort + fast too, not just the model name.
|
|
1210
|
+
override = session.get("model_override")
|
|
1211
|
+
override = override if isinstance(override, dict) else {}
|
|
1212
|
+
row_model = str(override.get("model") or "").strip() or _resolve_model()
|
|
1213
|
+
model_config: dict = {}
|
|
1214
|
+
for src_key, cfg_key in (
|
|
1215
|
+
("model", "model"),
|
|
1216
|
+
("provider", "provider"),
|
|
1217
|
+
("base_url", "base_url"),
|
|
1218
|
+
("api_mode", "api_mode"),
|
|
1219
|
+
):
|
|
1220
|
+
if val := override.get(src_key):
|
|
1221
|
+
model_config[cfg_key] = str(val)
|
|
1222
|
+
# The composer override may carry the RESOLVED provider "custom" for a named
|
|
1223
|
+
# ``providers:`` / ``custom_providers:`` entry. Persisting bare "custom" here
|
|
1224
|
+
# (the very first DB write for a fresh desktop session, before the agent is
|
|
1225
|
+
# built) is the origin of the recurring "No LLM provider configured" rows:
|
|
1226
|
+
# on the next resume bare "custom" routes to OpenRouter with no key. Recover
|
|
1227
|
+
# the durable ``custom:<name>`` identity from the override's base_url, else
|
|
1228
|
+
# the configured provider, so a routable identity is persisted from the
|
|
1229
|
+
# start (matches _runtime_model_config's normalization).
|
|
1230
|
+
if str(model_config.get("provider") or "").strip().lower() == "custom":
|
|
1231
|
+
try:
|
|
1232
|
+
from hermes_cli.runtime_provider import canonical_custom_identity
|
|
1233
|
+
|
|
1234
|
+
healed = canonical_custom_identity(
|
|
1235
|
+
base_url=model_config.get("base_url") or None
|
|
1236
|
+
)
|
|
1237
|
+
if healed:
|
|
1238
|
+
model_config["provider"] = healed
|
|
1239
|
+
except Exception:
|
|
1240
|
+
logger.debug(
|
|
1241
|
+
"custom provider identity recovery failed (db row)", exc_info=True
|
|
1242
|
+
)
|
|
1243
|
+
if (reasoning := session.get("create_reasoning_override")) is not None:
|
|
1244
|
+
model_config["reasoning_config"] = reasoning
|
|
1245
|
+
if tier := session.get("create_service_tier_override"):
|
|
1246
|
+
model_config["service_tier"] = tier
|
|
726
1247
|
try:
|
|
727
1248
|
db.create_session(
|
|
728
1249
|
key,
|
|
729
1250
|
source="tui",
|
|
730
|
-
model=
|
|
1251
|
+
model=row_model,
|
|
1252
|
+
model_config=model_config or None,
|
|
731
1253
|
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
|
|
732
1254
|
)
|
|
733
1255
|
except Exception:
|
|
734
1256
|
logger.debug("failed to persist desktop session row", exc_info=True)
|
|
1257
|
+
finally:
|
|
1258
|
+
if close_db:
|
|
1259
|
+
try:
|
|
1260
|
+
db.close()
|
|
1261
|
+
except Exception:
|
|
1262
|
+
pass
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
@contextlib.contextmanager
|
|
1266
|
+
def _session_db(session: dict):
|
|
1267
|
+
"""Yield the SessionDB that owns this session's row (profile-aware).
|
|
1268
|
+
|
|
1269
|
+
Mirrors :func:`_ensure_session_db_row`: a remote/profile session persists
|
|
1270
|
+
into its own profile's ``state.db`` (a fresh handle we close on exit);
|
|
1271
|
+
everything else borrows the shared ``_get_db()`` handle (left open). Yields
|
|
1272
|
+
None when the db is unavailable.
|
|
1273
|
+
"""
|
|
1274
|
+
db, close_db = None, False
|
|
1275
|
+
profile_home = session.get("profile_home")
|
|
1276
|
+
if profile_home:
|
|
1277
|
+
from hermes_state import SessionDB
|
|
1278
|
+
|
|
1279
|
+
try:
|
|
1280
|
+
db, close_db = SessionDB(db_path=Path(profile_home) / "state.db"), True
|
|
1281
|
+
except Exception:
|
|
1282
|
+
logger.debug("failed to open profile db for session", exc_info=True)
|
|
1283
|
+
else:
|
|
1284
|
+
db = _get_db()
|
|
1285
|
+
try:
|
|
1286
|
+
yield db
|
|
1287
|
+
finally:
|
|
1288
|
+
if close_db and db is not None:
|
|
1289
|
+
with contextlib.suppress(Exception):
|
|
1290
|
+
db.close()
|
|
735
1291
|
|
|
736
1292
|
|
|
737
1293
|
def _set_session_cwd(session: dict, cwd: str) -> str:
|
|
@@ -743,12 +1299,12 @@ def _set_session_cwd(session: dict, cwd: str) -> str:
|
|
|
743
1299
|
# lazy row creation persist it too, not the launch-dir fallback).
|
|
744
1300
|
session["explicit_cwd"] = True
|
|
745
1301
|
_register_session_cwd(session)
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1302
|
+
with _session_db(session) as db:
|
|
1303
|
+
if db is not None:
|
|
1304
|
+
try:
|
|
1305
|
+
db.update_session_cwd(session.get("session_key", ""), resolved)
|
|
1306
|
+
except Exception:
|
|
1307
|
+
logger.debug("failed to persist session cwd", exc_info=True)
|
|
752
1308
|
try:
|
|
753
1309
|
from tools.terminal_tool import cleanup_vm
|
|
754
1310
|
|
|
@@ -773,26 +1329,52 @@ def _load_cfg() -> dict:
|
|
|
773
1329
|
try:
|
|
774
1330
|
import yaml
|
|
775
1331
|
|
|
776
|
-
|
|
1332
|
+
# Honor a per-session profile override (see session.resume) so a resumed
|
|
1333
|
+
# remote profile loads ITS config (model, skills, prompt); otherwise the
|
|
1334
|
+
# launch profile's _hermes_home. Cache is keyed on the resolved path, so
|
|
1335
|
+
# profiles don't clobber each other.
|
|
1336
|
+
override = get_hermes_home_override()
|
|
1337
|
+
home = override if isinstance(override, str) and override else _hermes_home
|
|
1338
|
+
p = Path(home) / "config.yaml"
|
|
777
1339
|
mtime = p.stat().st_mtime if p.exists() else None
|
|
778
1340
|
with _cfg_lock:
|
|
779
1341
|
if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p:
|
|
780
|
-
return copy.deepcopy(_cfg_cache)
|
|
1342
|
+
return _apply_managed(copy.deepcopy(_cfg_cache))
|
|
781
1343
|
if p.exists():
|
|
782
1344
|
with open(p, encoding="utf-8") as f:
|
|
783
1345
|
data = yaml.safe_load(f) or {}
|
|
784
1346
|
else:
|
|
785
1347
|
data = {}
|
|
786
1348
|
with _cfg_lock:
|
|
1349
|
+
# Cache the RAW user config (no managed overlay) so _save_cfg, which
|
|
1350
|
+
# writes _cfg_cache back to disk, never persists managed values into
|
|
1351
|
+
# the user's file. The managed overlay is applied on every return
|
|
1352
|
+
# path instead (read-side only).
|
|
787
1353
|
_cfg_cache = copy.deepcopy(data)
|
|
788
1354
|
_cfg_mtime = mtime
|
|
789
1355
|
_cfg_path = p
|
|
790
|
-
return data
|
|
1356
|
+
return _apply_managed(data)
|
|
791
1357
|
except Exception:
|
|
792
1358
|
pass
|
|
793
1359
|
return {}
|
|
794
1360
|
|
|
795
1361
|
|
|
1362
|
+
def _apply_managed(cfg: dict) -> dict:
|
|
1363
|
+
"""Overlay administrator-pinned managed-scope values on a config dict.
|
|
1364
|
+
|
|
1365
|
+
The TUI/desktop backend builds config independently of
|
|
1366
|
+
hermes_cli.config.load_config, so without this a managed skin / reasoning_effort
|
|
1367
|
+
/ service_tier / provider_routing would be silently ignored here. Read-side
|
|
1368
|
+
only — the raw user config is what gets cached and saved. Fail-open.
|
|
1369
|
+
"""
|
|
1370
|
+
try:
|
|
1371
|
+
from hermes_cli import managed_scope
|
|
1372
|
+
|
|
1373
|
+
return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {})
|
|
1374
|
+
except Exception:
|
|
1375
|
+
return cfg
|
|
1376
|
+
|
|
1377
|
+
|
|
796
1378
|
def _save_cfg(cfg: dict):
|
|
797
1379
|
global _cfg_cache, _cfg_mtime, _cfg_path
|
|
798
1380
|
import yaml
|
|
@@ -809,11 +1391,32 @@ def _save_cfg(cfg: dict):
|
|
|
809
1391
|
_cfg_mtime = None
|
|
810
1392
|
|
|
811
1393
|
|
|
812
|
-
def
|
|
1394
|
+
def _cwd_for_session_key(session_key: str) -> str:
|
|
1395
|
+
"""Reverse-map session_key to the session's logical cwd.
|
|
1396
|
+
|
|
1397
|
+
Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the
|
|
1398
|
+
thread pool, so iterating the live view risks ``RuntimeError: dictionary
|
|
1399
|
+
changed size during iteration``.
|
|
1400
|
+
"""
|
|
1401
|
+
if not session_key:
|
|
1402
|
+
return ""
|
|
1403
|
+
with _sessions_lock:
|
|
1404
|
+
for sess in list(_sessions.values()):
|
|
1405
|
+
if sess.get("session_key") == session_key:
|
|
1406
|
+
return str(sess.get("cwd") or "")
|
|
1407
|
+
return ""
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _set_session_context(session_key: str, cwd: str | None = None) -> list:
|
|
813
1411
|
try:
|
|
814
1412
|
from gateway.session_context import set_session_vars
|
|
815
1413
|
|
|
816
|
-
|
|
1414
|
+
# Ephemeral task IDs (background, preview) aren't in `_sessions`, so the
|
|
1415
|
+
# reverse-map returns "" and would clear the cwd override. Callers that
|
|
1416
|
+
# know the parent workspace pass it explicitly so spawned agents inherit
|
|
1417
|
+
# it instead of falling back to the gateway launch dir.
|
|
1418
|
+
resolved = cwd if cwd is not None else _cwd_for_session_key(session_key)
|
|
1419
|
+
return set_session_vars(session_key=session_key, cwd=resolved)
|
|
817
1420
|
except Exception:
|
|
818
1421
|
return []
|
|
819
1422
|
|
|
@@ -842,16 +1445,19 @@ def _enable_gateway_prompts() -> None:
|
|
|
842
1445
|
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
|
843
1446
|
rid = uuid.uuid4().hex[:8]
|
|
844
1447
|
ev = threading.Event()
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1448
|
+
with _prompt_lock:
|
|
1449
|
+
_pending[rid] = (sid, ev)
|
|
1450
|
+
payload["request_id"] = rid
|
|
1451
|
+
_pending_prompt_payloads[rid] = (event, dict(payload))
|
|
848
1452
|
try:
|
|
849
1453
|
_emit(event, sid, payload)
|
|
850
1454
|
ev.wait(timeout=timeout)
|
|
851
1455
|
finally:
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1456
|
+
with _prompt_lock:
|
|
1457
|
+
_pending.pop(rid, None)
|
|
1458
|
+
_pending_prompt_payloads.pop(rid, None)
|
|
1459
|
+
with _prompt_lock:
|
|
1460
|
+
return _answers.pop(rid, "")
|
|
855
1461
|
|
|
856
1462
|
|
|
857
1463
|
def _clear_pending(sid: str | None = None) -> None:
|
|
@@ -863,10 +1469,11 @@ def _clear_pending(sid: str | None = None) -> None:
|
|
|
863
1469
|
sessions sharing the same tui_gateway process. When *sid* is
|
|
864
1470
|
None, every pending prompt is released (used during shutdown).
|
|
865
1471
|
"""
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1472
|
+
with _prompt_lock:
|
|
1473
|
+
for rid, (owner_sid, ev) in list(_pending.items()):
|
|
1474
|
+
if sid is None or owner_sid == sid:
|
|
1475
|
+
_answers[rid] = ""
|
|
1476
|
+
ev.set()
|
|
870
1477
|
|
|
871
1478
|
|
|
872
1479
|
# ── Agent factory ────────────────────────────────────────────────────
|
|
@@ -906,6 +1513,31 @@ def _resolve_model() -> str:
|
|
|
906
1513
|
return "anthropic/claude-sonnet-4"
|
|
907
1514
|
|
|
908
1515
|
|
|
1516
|
+
def _config_model_target() -> tuple[str, str]:
|
|
1517
|
+
"""(model, provider) currently selected by config (env as fallback).
|
|
1518
|
+
|
|
1519
|
+
config.yaml wins over HERMES_MODEL / HERMES_INFERENCE_MODEL here, the
|
|
1520
|
+
reverse of `_resolve_model()`'s startup order. Those env vars are a
|
|
1521
|
+
provision-time seed (hosted instances set HERMES_INFERENCE_MODEL in the
|
|
1522
|
+
container env); if they outranked config.yaml, the per-turn sync would
|
|
1523
|
+
stay pinned to the seed forever and dashboard/CLI model changes would
|
|
1524
|
+
never reach an open chat — the exact bug this sync exists to fix.
|
|
1525
|
+
"""
|
|
1526
|
+
cfg_model = _load_cfg().get("model")
|
|
1527
|
+
model = ""
|
|
1528
|
+
provider = ""
|
|
1529
|
+
if isinstance(cfg_model, dict):
|
|
1530
|
+
model = str(cfg_model.get("default", "") or "").strip()
|
|
1531
|
+
provider = str(cfg_model.get("provider") or "").strip()
|
|
1532
|
+
if provider.lower() == "auto":
|
|
1533
|
+
provider = ""
|
|
1534
|
+
elif isinstance(cfg_model, str):
|
|
1535
|
+
model = cfg_model.strip()
|
|
1536
|
+
if not model:
|
|
1537
|
+
model = _resolve_model()
|
|
1538
|
+
return model, provider
|
|
1539
|
+
|
|
1540
|
+
|
|
909
1541
|
def _resolve_startup_runtime() -> tuple[str, str | None]:
|
|
910
1542
|
model = _resolve_model()
|
|
911
1543
|
explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip()
|
|
@@ -941,42 +1573,288 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:
|
|
|
941
1573
|
return model, None
|
|
942
1574
|
|
|
943
1575
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
keys = key_path.split(".")
|
|
948
|
-
for key in keys[:-1]:
|
|
949
|
-
if key not in current or not isinstance(current.get(key), dict):
|
|
950
|
-
current[key] = {}
|
|
951
|
-
current = current[key]
|
|
952
|
-
current[keys[-1]] = value
|
|
953
|
-
_save_cfg(cfg)
|
|
1576
|
+
# Bare billing buckets are not routable provider identities (kept in parity with the
|
|
1577
|
+
# provider gate in agent_init). Restoring one as a session provider override breaks resume.
|
|
1578
|
+
_BARE_BILLING_PROVIDERS = {"auto", "openrouter", "custom"}
|
|
954
1579
|
|
|
955
1580
|
|
|
956
|
-
|
|
1581
|
+
def _stored_session_runtime_overrides(row: dict | None) -> dict:
|
|
1582
|
+
"""Return runtime fields persisted with a stored session.
|
|
957
1583
|
|
|
1584
|
+
``session.resume`` is a session-scoped operation: reopening an older chat
|
|
1585
|
+
must restore the model/provider/reasoning state that chat actually used,
|
|
1586
|
+
not whatever global model the user most recently selected in another chat.
|
|
1587
|
+
The durable session row stores the model directly, the billing provider in
|
|
1588
|
+
``billing_provider``, and richer runtime knobs in JSON ``model_config``.
|
|
1589
|
+
"""
|
|
1590
|
+
if not row:
|
|
1591
|
+
return {}
|
|
958
1592
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1593
|
+
raw_config = row.get("model_config")
|
|
1594
|
+
model_config: dict = {}
|
|
1595
|
+
if isinstance(raw_config, dict):
|
|
1596
|
+
model_config = raw_config
|
|
1597
|
+
elif isinstance(raw_config, str) and raw_config.strip():
|
|
1598
|
+
try:
|
|
1599
|
+
parsed = json.loads(raw_config)
|
|
1600
|
+
if isinstance(parsed, dict):
|
|
1601
|
+
model_config = parsed
|
|
1602
|
+
except Exception:
|
|
1603
|
+
logger.debug("failed to parse stored session model_config", exc_info=True)
|
|
1604
|
+
|
|
1605
|
+
overrides: dict = {}
|
|
1606
|
+
model = str(row.get("model") or model_config.get("model") or "").strip()
|
|
1607
|
+
# ``billing_provider`` is only the billing bucket — for a custom endpoint it is the
|
|
1608
|
+
# bare class ``"custom"``, which agent_init treats as non-routable, so restoring it as
|
|
1609
|
+
# the provider override makes ``session.resume`` fail with "No LLM provider configured".
|
|
1610
|
+
# Only restore an explicit provider; otherwise leave it unset so resume falls back to
|
|
1611
|
+
# the configured default, matching the working CLI path.
|
|
1612
|
+
explicit_provider = str(model_config.get("provider") or "").strip()
|
|
1613
|
+
billing_provider = str(
|
|
1614
|
+
model_config.get("billing_provider") or row.get("billing_provider") or ""
|
|
1615
|
+
).strip()
|
|
1616
|
+
provider = explicit_provider
|
|
1617
|
+
if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:
|
|
1618
|
+
provider = billing_provider
|
|
1619
|
+
base_url = str(model_config.get("base_url") or "").strip()
|
|
1620
|
+
api_mode = str(model_config.get("api_mode") or "").strip()
|
|
1621
|
+
reasoning_config = model_config.get("reasoning_config")
|
|
1622
|
+
service_tier = str(model_config.get("service_tier") or "").strip()
|
|
1623
|
+
|
|
1624
|
+
# Heal a bare ``"custom"`` provider stored by an older build (or any leak
|
|
1625
|
+
# site that bypassed _runtime_model_config's normalization). Bare custom is
|
|
1626
|
+
# the resolved billing class, not a routable identity — restoring it as the
|
|
1627
|
+
# session's provider override routes the resume to the OpenRouter default
|
|
1628
|
+
# URL with no api_key, surfacing as "No LLM provider configured". Recover
|
|
1629
|
+
# the durable ``custom:<name>`` menu key from the stored base_url, falling
|
|
1630
|
+
# back to the configured provider when the row has no base_url (the
|
|
1631
|
+
# recurring Desktop/TUI regression vector). If neither names a real entry,
|
|
1632
|
+
# drop the bare provider entirely so resume falls back to the configured
|
|
1633
|
+
# default rather than the broken OpenRouter route.
|
|
1634
|
+
if provider.strip().lower() == "custom":
|
|
1635
|
+
healed = None
|
|
1636
|
+
try:
|
|
1637
|
+
from hermes_cli.runtime_provider import canonical_custom_identity
|
|
965
1638
|
|
|
1639
|
+
healed = canonical_custom_identity(base_url=base_url or None)
|
|
1640
|
+
except Exception:
|
|
1641
|
+
logger.debug(
|
|
1642
|
+
"custom provider identity recovery failed", exc_info=True
|
|
1643
|
+
)
|
|
1644
|
+
provider = healed or ("" if not base_url else provider)
|
|
1645
|
+
|
|
1646
|
+
if model:
|
|
1647
|
+
# Use the same dict-shaped override that live /model switches use so a
|
|
1648
|
+
# DB-restored session can preserve custom endpoint metadata across both
|
|
1649
|
+
# initial resume and later rebuilds (/new). Deliberately do not persist
|
|
1650
|
+
# or restore raw api_key here; endpoint credentials should continue to
|
|
1651
|
+
# come from config/env/provider resolution rather than the session DB.
|
|
1652
|
+
overrides["model_override"] = {
|
|
1653
|
+
"model": model,
|
|
1654
|
+
"provider": provider or None,
|
|
1655
|
+
"base_url": base_url or None,
|
|
1656
|
+
"api_mode": api_mode or None,
|
|
1657
|
+
}
|
|
1658
|
+
if provider:
|
|
1659
|
+
overrides["provider_override"] = provider
|
|
1660
|
+
if isinstance(reasoning_config, dict):
|
|
1661
|
+
overrides["reasoning_config_override"] = reasoning_config
|
|
1662
|
+
if service_tier:
|
|
1663
|
+
overrides["service_tier_override"] = service_tier
|
|
1664
|
+
|
|
1665
|
+
return overrides
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _runtime_model_config(agent, existing: dict | None = None) -> dict:
|
|
1669
|
+
config = dict(existing or {})
|
|
1670
|
+
model = str(getattr(agent, "model", "") or "").strip()
|
|
1671
|
+
provider = str(getattr(agent, "provider", "") or "").strip()
|
|
1672
|
+
base_url = str(getattr(agent, "base_url", "") or "").strip()
|
|
1673
|
+
api_mode = str(getattr(agent, "api_mode", "") or "").strip()
|
|
1674
|
+
reasoning_config = getattr(agent, "reasoning_config", None)
|
|
1675
|
+
service_tier = getattr(agent, "service_tier", None)
|
|
1676
|
+
|
|
1677
|
+
if model:
|
|
1678
|
+
config["model"] = model
|
|
1679
|
+
if provider:
|
|
1680
|
+
if provider.strip().lower() == "custom":
|
|
1681
|
+
# ``agent.provider`` is the RESOLVED provider, and for any named
|
|
1682
|
+
# ``providers:`` / ``custom_providers:`` entry that is the literal
|
|
1683
|
+
# string "custom" — persisting it loses the entry identity, so a
|
|
1684
|
+
# later resume/rebuild cannot re-resolve the entry's credentials
|
|
1685
|
+
# (the api_key is deliberately never persisted; see
|
|
1686
|
+
# _stored_session_runtime_overrides). Recover the canonical
|
|
1687
|
+
# ``custom:<name>`` menu key from the endpoint URL when present,
|
|
1688
|
+
# else from the configured provider — this second fallback is the
|
|
1689
|
+
# fix for sessions built WITHOUT a base_url on the override (the
|
|
1690
|
+
# recurring Desktop/TUI "No LLM provider configured" regression:
|
|
1691
|
+
# bare "custom" with no base_url was persisted verbatim and routed
|
|
1692
|
+
# to OpenRouter with no key on the next resume).
|
|
1693
|
+
try:
|
|
1694
|
+
from hermes_cli.runtime_provider import (
|
|
1695
|
+
canonical_custom_identity,
|
|
1696
|
+
)
|
|
966
1697
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1698
|
+
provider = (
|
|
1699
|
+
canonical_custom_identity(base_url=base_url) or provider
|
|
1700
|
+
)
|
|
1701
|
+
except Exception:
|
|
1702
|
+
logger.debug(
|
|
1703
|
+
"custom provider identity lookup failed", exc_info=True
|
|
1704
|
+
)
|
|
1705
|
+
config["provider"] = provider
|
|
1706
|
+
if base_url:
|
|
1707
|
+
config["base_url"] = base_url
|
|
1708
|
+
else:
|
|
1709
|
+
config.pop("base_url", None)
|
|
1710
|
+
if api_mode:
|
|
1711
|
+
config["api_mode"] = api_mode
|
|
1712
|
+
else:
|
|
1713
|
+
config.pop("api_mode", None)
|
|
1714
|
+
if isinstance(reasoning_config, dict):
|
|
1715
|
+
config["reasoning_config"] = reasoning_config
|
|
1716
|
+
else:
|
|
1717
|
+
config.pop("reasoning_config", None)
|
|
1718
|
+
if service_tier:
|
|
1719
|
+
config["service_tier"] = service_tier
|
|
1720
|
+
else:
|
|
1721
|
+
config.pop("service_tier", None)
|
|
1722
|
+
|
|
1723
|
+
return config
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _persist_live_session_runtime(session: dict | None) -> None:
|
|
1727
|
+
"""Persist active session runtime so future resumes restore the same footer."""
|
|
1728
|
+
if not session:
|
|
1729
|
+
return
|
|
1730
|
+
agent = session.get("agent")
|
|
1731
|
+
session_key = str(session.get("session_key") or "").strip()
|
|
1732
|
+
if agent is None or not session_key:
|
|
1733
|
+
return
|
|
1734
|
+
|
|
1735
|
+
db = getattr(agent, "_session_db", None) or _get_db()
|
|
1736
|
+
if db is None:
|
|
1737
|
+
return
|
|
1738
|
+
|
|
1739
|
+
try:
|
|
1740
|
+
row = db.get_session(session_key) or {}
|
|
1741
|
+
raw_config = row.get("model_config")
|
|
1742
|
+
existing_config = {}
|
|
1743
|
+
if isinstance(raw_config, dict):
|
|
1744
|
+
existing_config = raw_config
|
|
1745
|
+
elif isinstance(raw_config, str) and raw_config.strip():
|
|
1746
|
+
parsed = json.loads(raw_config)
|
|
1747
|
+
if isinstance(parsed, dict):
|
|
1748
|
+
existing_config = parsed
|
|
1749
|
+
model_config = _runtime_model_config(agent, existing_config)
|
|
1750
|
+
model = str(getattr(agent, "model", "") or "").strip()
|
|
1751
|
+
if hasattr(db, "update_session_meta"):
|
|
1752
|
+
db.update_session_meta(session_key, json.dumps(model_config), model or None)
|
|
1753
|
+
elif model and hasattr(db, "update_session_model"):
|
|
1754
|
+
db.update_session_model(session_key, model)
|
|
1755
|
+
except Exception:
|
|
1756
|
+
logger.debug("failed to persist live session runtime", exc_info=True)
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def _persist_live_session_system_prompt(session: dict | None) -> None:
|
|
1760
|
+
"""Refresh the stored system prompt after a live runtime identity change."""
|
|
1761
|
+
if not session:
|
|
1762
|
+
return
|
|
1763
|
+
agent = session.get("agent")
|
|
1764
|
+
session_key = str(session.get("session_key") or "").strip()
|
|
1765
|
+
if agent is None or not session_key or not hasattr(agent, "_build_system_prompt"):
|
|
1766
|
+
return
|
|
1767
|
+
|
|
1768
|
+
db = getattr(agent, "_session_db", None) or _get_db()
|
|
1769
|
+
if db is None or not hasattr(db, "update_system_prompt"):
|
|
1770
|
+
return
|
|
1771
|
+
|
|
1772
|
+
try:
|
|
1773
|
+
prompt = agent._build_system_prompt(None)
|
|
1774
|
+
agent._cached_system_prompt = prompt
|
|
1775
|
+
db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt)
|
|
1776
|
+
except Exception:
|
|
1777
|
+
logger.debug("failed to persist live session system prompt", exc_info=True)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None:
|
|
1781
|
+
"""Record a real system-history pivot after a live model switch."""
|
|
1782
|
+
if not session:
|
|
1783
|
+
return
|
|
1784
|
+
session_key = str(session.get("session_key") or "").strip()
|
|
1785
|
+
if not session_key:
|
|
1786
|
+
return
|
|
1787
|
+
|
|
1788
|
+
provider_part = f" via provider {provider}" if provider else ""
|
|
1789
|
+
marker = (
|
|
1790
|
+
"[System: The active model for this chat has changed to "
|
|
1791
|
+
f"{model}{provider_part}. From this point forward, use this runtime "
|
|
1792
|
+
"metadata when answering questions about what model/provider is active.]"
|
|
1793
|
+
)
|
|
1794
|
+
entry = {"role": "system", "content": marker}
|
|
1795
|
+
|
|
1796
|
+
lock = session.get("history_lock")
|
|
1797
|
+
if lock is not None:
|
|
1798
|
+
with lock:
|
|
1799
|
+
session.setdefault("history", []).append(entry)
|
|
1800
|
+
session["history_version"] = int(session.get("history_version", 0)) + 1
|
|
1801
|
+
else:
|
|
1802
|
+
session.setdefault("history", []).append(entry)
|
|
1803
|
+
session["history_version"] = int(session.get("history_version", 0)) + 1
|
|
1804
|
+
|
|
1805
|
+
try:
|
|
1806
|
+
agent = session.get("agent")
|
|
1807
|
+
db = getattr(agent, "_session_db", None) if agent is not None else None
|
|
1808
|
+
if db is not None:
|
|
1809
|
+
db.append_message(session_id=session_key, role="system", content=marker)
|
|
1810
|
+
return
|
|
1811
|
+
|
|
1812
|
+
_ensure_session_db_row(session)
|
|
1813
|
+
with _session_db(session) as scoped_db:
|
|
1814
|
+
if scoped_db is not None:
|
|
1815
|
+
scoped_db.append_message(
|
|
1816
|
+
session_id=session_key, role="system", content=marker
|
|
1817
|
+
)
|
|
1818
|
+
except Exception:
|
|
1819
|
+
logger.debug("failed to persist model switch marker", exc_info=True)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def _write_config_key(key_path: str, value):
|
|
1823
|
+
cfg = _load_cfg()
|
|
1824
|
+
current = cfg
|
|
1825
|
+
keys = key_path.split(".")
|
|
1826
|
+
for key in keys[:-1]:
|
|
1827
|
+
if key not in current or not isinstance(current.get(key), dict):
|
|
1828
|
+
current[key] = {}
|
|
1829
|
+
current = current[key]
|
|
1830
|
+
current[keys[-1]] = value
|
|
1831
|
+
_save_cfg(cfg)
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
_STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def _coerce_statusbar(raw) -> str:
|
|
1838
|
+
if raw is False:
|
|
1839
|
+
return "off"
|
|
1840
|
+
if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES:
|
|
1841
|
+
return s
|
|
1842
|
+
return "top"
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
_MOUSE_TRACKING_ALIASES = {
|
|
1846
|
+
"0": "off",
|
|
1847
|
+
"1": "all",
|
|
1848
|
+
"all": "all",
|
|
1849
|
+
"any": "all",
|
|
1850
|
+
"button": "buttons",
|
|
1851
|
+
"buttons": "buttons",
|
|
1852
|
+
"click": "buttons",
|
|
1853
|
+
"false": "off",
|
|
1854
|
+
"full": "all",
|
|
1855
|
+
"no": "off",
|
|
1856
|
+
"off": "off",
|
|
1857
|
+
"on": "all",
|
|
980
1858
|
"scroll": "wheel",
|
|
981
1859
|
"true": "all",
|
|
982
1860
|
"wheel": "wheel",
|
|
@@ -1032,10 +1910,40 @@ def _load_service_tier() -> str | None:
|
|
|
1032
1910
|
return None
|
|
1033
1911
|
|
|
1034
1912
|
|
|
1913
|
+
def _load_provider_routing() -> dict:
|
|
1914
|
+
"""OpenRouter provider-routing prefs from config.yaml (``provider_routing``).
|
|
1915
|
+
|
|
1916
|
+
Parity with the messaging gateway (``gateway/run.py::_load_provider_routing``)
|
|
1917
|
+
and the classic CLI: without this the desktop/TUI backend builds agents with
|
|
1918
|
+
no routing prefs, so OpenRouter falls back to its default (effectively random)
|
|
1919
|
+
provider selection even when the user configured ``provider_routing``.
|
|
1920
|
+
"""
|
|
1921
|
+
try:
|
|
1922
|
+
return _load_cfg().get("provider_routing", {}) or {}
|
|
1923
|
+
except Exception:
|
|
1924
|
+
return {}
|
|
1925
|
+
|
|
1926
|
+
|
|
1035
1927
|
def _load_show_reasoning() -> bool:
|
|
1036
1928
|
return bool((_load_cfg().get("display") or {}).get("show_reasoning", False))
|
|
1037
1929
|
|
|
1038
1930
|
|
|
1931
|
+
def _load_memory_notifications() -> str:
|
|
1932
|
+
"""Self-improvement review notification mode from config.yaml.
|
|
1933
|
+
|
|
1934
|
+
Parity with the messaging gateway (``gateway/run.py``) and the classic CLI:
|
|
1935
|
+
``display.memory_notifications`` controls whether the background review's
|
|
1936
|
+
"💾 Self-improvement review: …" summary is surfaced. Without this the
|
|
1937
|
+
TUI/desktop backend always behaved as ``"on"`` and silently ignored a user
|
|
1938
|
+
who set ``off``. Accepts ``off`` / ``on`` (default) / ``verbose``; a bool is
|
|
1939
|
+
normalized for back-compat.
|
|
1940
|
+
"""
|
|
1941
|
+
raw = (_load_cfg().get("display") or {}).get("memory_notifications")
|
|
1942
|
+
if isinstance(raw, bool):
|
|
1943
|
+
return "on" if raw else "off"
|
|
1944
|
+
return str(raw).lower() if raw else "on"
|
|
1945
|
+
|
|
1946
|
+
|
|
1039
1947
|
def _load_tool_progress_mode() -> str:
|
|
1040
1948
|
env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
|
|
1041
1949
|
if env in {"off", "new", "all", "verbose"}:
|
|
@@ -1058,6 +1966,22 @@ def _load_enabled_toolsets() -> list[str] | None:
|
|
|
1058
1966
|
cfg = None
|
|
1059
1967
|
fallback_notice = None
|
|
1060
1968
|
|
|
1969
|
+
# Coding posture (base Hermes): with no explicit pin, collapse to the
|
|
1970
|
+
# coding toolset (+ enabled MCP servers) when sitting in a code workspace.
|
|
1971
|
+
# The desktop app and `hermes --tui` both land here. See
|
|
1972
|
+
# agent/coding_context.py. No config is loaded yet at this point, so we let
|
|
1973
|
+
# coding_selection() load it lazily (cli.py passes its already-resolved
|
|
1974
|
+
# CLI_CONFIG instead, purely to avoid a redundant read).
|
|
1975
|
+
if not explicit:
|
|
1976
|
+
try:
|
|
1977
|
+
from agent.coding_context import coding_selection
|
|
1978
|
+
|
|
1979
|
+
selection = coding_selection(platform="tui")
|
|
1980
|
+
if selection is not None:
|
|
1981
|
+
return selection
|
|
1982
|
+
except Exception:
|
|
1983
|
+
pass
|
|
1984
|
+
|
|
1061
1985
|
try:
|
|
1062
1986
|
from toolsets import validate_toolset
|
|
1063
1987
|
except Exception:
|
|
@@ -1188,7 +2112,7 @@ def _tool_progress_enabled(sid: str) -> bool:
|
|
|
1188
2112
|
return _session_tool_progress_mode(sid) != "off"
|
|
1189
2113
|
|
|
1190
2114
|
|
|
1191
|
-
def _restart_slash_worker(session: dict):
|
|
2115
|
+
def _restart_slash_worker(sid: str, session: dict):
|
|
1192
2116
|
worker = session.get("slash_worker")
|
|
1193
2117
|
if worker:
|
|
1194
2118
|
try:
|
|
@@ -1196,12 +2120,18 @@ def _restart_slash_worker(session: dict):
|
|
|
1196
2120
|
except Exception:
|
|
1197
2121
|
pass
|
|
1198
2122
|
try:
|
|
1199
|
-
|
|
2123
|
+
new_worker = _SlashWorker(
|
|
1200
2124
|
session["session_key"],
|
|
1201
2125
|
getattr(session.get("agent"), "model", _resolve_model()),
|
|
1202
2126
|
)
|
|
1203
2127
|
except Exception:
|
|
1204
2128
|
session["slash_worker"] = None
|
|
2129
|
+
return
|
|
2130
|
+
# Route through the same store-iff-still-mapped guard as the spawn sites:
|
|
2131
|
+
# the post-turn restart runs as `running` flips false, exactly when a
|
|
2132
|
+
# close_on_disconnect reap can pop this session — a bare store would orphan
|
|
2133
|
+
# the fresh worker (it self-heals only on gateway exit via the watchdog).
|
|
2134
|
+
_attach_worker(sid, session, new_worker)
|
|
1205
2135
|
|
|
1206
2136
|
|
|
1207
2137
|
def _persist_model_switch(result) -> None:
|
|
@@ -1222,11 +2152,32 @@ def _persist_model_switch(result) -> None:
|
|
|
1222
2152
|
save_config(cfg)
|
|
1223
2153
|
|
|
1224
2154
|
|
|
1225
|
-
def _apply_model_switch(
|
|
1226
|
-
|
|
2155
|
+
def _apply_model_switch(
|
|
2156
|
+
sid: str,
|
|
2157
|
+
session: dict,
|
|
2158
|
+
raw_input: str,
|
|
2159
|
+
*,
|
|
2160
|
+
confirm_expensive_model: bool = False,
|
|
2161
|
+
pin_session_override: bool = True,
|
|
2162
|
+
parsed_flags: tuple[str, str, bool, bool, bool] | None = None,
|
|
2163
|
+
) -> dict:
|
|
2164
|
+
from hermes_cli.model_switch import (
|
|
2165
|
+
parse_model_flags,
|
|
2166
|
+
resolve_persist_behavior,
|
|
2167
|
+
switch_model,
|
|
2168
|
+
)
|
|
1227
2169
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
1228
2170
|
|
|
1229
|
-
|
|
2171
|
+
if parsed_flags is None:
|
|
2172
|
+
parsed_flags = parse_model_flags(raw_input)
|
|
2173
|
+
(
|
|
2174
|
+
model_input,
|
|
2175
|
+
explicit_provider,
|
|
2176
|
+
is_global_flag,
|
|
2177
|
+
_force_refresh,
|
|
2178
|
+
is_session,
|
|
2179
|
+
) = parsed_flags
|
|
2180
|
+
persist_global = resolve_persist_behavior(is_global_flag, is_session)
|
|
1230
2181
|
if not model_input:
|
|
1231
2182
|
raise ValueError("model value required")
|
|
1232
2183
|
|
|
@@ -1237,20 +2188,24 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
|
|
1237
2188
|
current_base_url = getattr(agent, "base_url", "") or ""
|
|
1238
2189
|
current_api_key = getattr(agent, "api_key", "") or ""
|
|
1239
2190
|
else:
|
|
1240
|
-
runtime = resolve_runtime_provider(requested=None)
|
|
1241
|
-
current_provider = str(runtime.get("provider", "") or "")
|
|
1242
2191
|
current_model = _resolve_model()
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
2192
|
+
current_provider = explicit_provider.strip()
|
|
2193
|
+
current_base_url = ""
|
|
2194
|
+
current_api_key = ""
|
|
2195
|
+
if not explicit_provider:
|
|
2196
|
+
runtime = resolve_runtime_provider(requested=None)
|
|
2197
|
+
current_provider = str(runtime.get("provider", "") or "")
|
|
2198
|
+
current_base_url = str(runtime.get("base_url", "") or "")
|
|
2199
|
+
# Preserve a callable api_key (Azure Foundry Entra ID bearer
|
|
2200
|
+
# provider) unchanged — ``str(...)`` would produce
|
|
2201
|
+
# ``"<function ...>"`` and poison downstream switch_model
|
|
2202
|
+
# validation. Match the agent-present branch's behavior at the
|
|
2203
|
+
# top of this block.
|
|
2204
|
+
_runtime_key = runtime.get("api_key", "")
|
|
2205
|
+
if callable(_runtime_key) and not isinstance(_runtime_key, str):
|
|
2206
|
+
current_api_key = _runtime_key
|
|
2207
|
+
else:
|
|
2208
|
+
current_api_key = str(_runtime_key or "")
|
|
1254
2209
|
|
|
1255
2210
|
# Load user-defined providers so switch_model can resolve named custom
|
|
1256
2211
|
# endpoints (e.g. "ollama-launch") and validate against saved model lists.
|
|
@@ -1279,6 +2234,27 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
|
|
1279
2234
|
if not result.success:
|
|
1280
2235
|
raise ValueError(result.error_message or "model switch failed")
|
|
1281
2236
|
|
|
2237
|
+
if not confirm_expensive_model:
|
|
2238
|
+
try:
|
|
2239
|
+
from hermes_cli.model_cost_guard import expensive_model_warning
|
|
2240
|
+
|
|
2241
|
+
warning = expensive_model_warning(
|
|
2242
|
+
result.new_model,
|
|
2243
|
+
provider=result.target_provider,
|
|
2244
|
+
base_url=result.base_url or current_base_url,
|
|
2245
|
+
api_key=result.api_key or current_api_key,
|
|
2246
|
+
model_info=result.model_info,
|
|
2247
|
+
)
|
|
2248
|
+
except Exception:
|
|
2249
|
+
warning = None
|
|
2250
|
+
if warning is not None:
|
|
2251
|
+
return {
|
|
2252
|
+
"value": result.new_model,
|
|
2253
|
+
"warning": warning.message,
|
|
2254
|
+
"confirm_required": True,
|
|
2255
|
+
"confirm_message": warning.message,
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1282
2258
|
if agent:
|
|
1283
2259
|
agent.switch_model(
|
|
1284
2260
|
new_model=result.new_model,
|
|
@@ -1287,27 +2263,83 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
|
|
1287
2263
|
base_url=result.base_url,
|
|
1288
2264
|
api_mode=result.api_mode,
|
|
1289
2265
|
)
|
|
1290
|
-
_restart_slash_worker(session)
|
|
2266
|
+
_restart_slash_worker(sid, session)
|
|
2267
|
+
_persist_live_session_runtime(session)
|
|
2268
|
+
_persist_live_session_system_prompt(session)
|
|
2269
|
+
_append_model_switch_marker(
|
|
2270
|
+
session, model=result.new_model, provider=result.target_provider
|
|
2271
|
+
)
|
|
1291
2272
|
_emit("session.info", sid, _session_info(agent, session))
|
|
1292
2273
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
#
|
|
1296
|
-
# explicit choice so any ambient re-resolution (credential pool refresh,
|
|
1297
|
-
# compressor rebuild, aux clients) and startup re-resolution on /new
|
|
1298
|
-
# both pick up the new provider instead of the original one persisted
|
|
1299
|
-
# in config or env.
|
|
2274
|
+
# Record the switch as a PER-SESSION override so a later rebuild of THIS
|
|
2275
|
+
# session (e.g. /new via _reset_session_agent, or resume) re-derives the
|
|
2276
|
+
# user's chosen model/provider instead of falling back to global config.
|
|
1300
2277
|
#
|
|
1301
|
-
#
|
|
1302
|
-
#
|
|
1303
|
-
#
|
|
1304
|
-
#
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
2278
|
+
# We deliberately do NOT write process-global env vars (HERMES_MODEL /
|
|
2279
|
+
# HERMES_INFERENCE_MODEL / HERMES_TUI_PROVIDER / HERMES_INFERENCE_PROVIDER)
|
|
2280
|
+
# here. The desktop backend hosts every same-profile session in ONE process,
|
|
2281
|
+
# so mutating os.environ on a /model switch leaked the new model/provider
|
|
2282
|
+
# into every OTHER live session's next agent rebuild — switching the model
|
|
2283
|
+
# in one session silently changed it in the others (the cross-session
|
|
2284
|
+
# contamination bug). agent.switch_model() above already mutated the right
|
|
2285
|
+
# agent in place; the override dict makes that choice survive a rebuild
|
|
2286
|
+
# without touching shared process state.
|
|
2287
|
+
if pin_session_override and isinstance(session, dict):
|
|
2288
|
+
session["model_override"] = {
|
|
2289
|
+
"model": result.new_model,
|
|
2290
|
+
"provider": result.target_provider,
|
|
2291
|
+
"base_url": result.base_url,
|
|
2292
|
+
"api_key": result.api_key,
|
|
2293
|
+
"api_mode": result.api_mode,
|
|
2294
|
+
}
|
|
1308
2295
|
if persist_global:
|
|
1309
2296
|
_persist_model_switch(result)
|
|
1310
|
-
return {
|
|
2297
|
+
return {
|
|
2298
|
+
"value": result.new_model,
|
|
2299
|
+
"warning": result.warning_message or "",
|
|
2300
|
+
"confirm_required": False,
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _sync_agent_model_with_config(sid: str, session: dict) -> None:
|
|
2305
|
+
"""Adopt a config.yaml model change at turn start, like gateways do per
|
|
2306
|
+
message. Sessions pinned with /model keep their choice; a failed switch
|
|
2307
|
+
keeps the current model and never blocks the turn.
|
|
2308
|
+
"""
|
|
2309
|
+
agent = session.get("agent")
|
|
2310
|
+
if agent is None or session.get("model_override"):
|
|
2311
|
+
return
|
|
2312
|
+
target = _config_model_target()
|
|
2313
|
+
if not target[0]:
|
|
2314
|
+
return
|
|
2315
|
+
seen = session.get("config_model_seen")
|
|
2316
|
+
# Record first so a broken config gets one attempt per edit, not per turn.
|
|
2317
|
+
session["config_model_seen"] = target
|
|
2318
|
+
if target == seen:
|
|
2319
|
+
return
|
|
2320
|
+
model, provider = target
|
|
2321
|
+
# Already running the configured model (branched/resumed session before
|
|
2322
|
+
# its first sync, or a config revert after a failed switch): adopt the
|
|
2323
|
+
# baseline without a redundant switch.
|
|
2324
|
+
if model == getattr(agent, "model", "") and (
|
|
2325
|
+
not provider or provider == getattr(agent, "provider", "")
|
|
2326
|
+
):
|
|
2327
|
+
return
|
|
2328
|
+
raw = f"{model} --provider {provider}" if provider else model
|
|
2329
|
+
try:
|
|
2330
|
+
_apply_model_switch(
|
|
2331
|
+
sid,
|
|
2332
|
+
session,
|
|
2333
|
+
raw,
|
|
2334
|
+
confirm_expensive_model=True,
|
|
2335
|
+
pin_session_override=False,
|
|
2336
|
+
)
|
|
2337
|
+
except Exception as e:
|
|
2338
|
+
_emit(
|
|
2339
|
+
"error",
|
|
2340
|
+
sid,
|
|
2341
|
+
{"message": f"Could not switch to configured model {model}: {e}"},
|
|
2342
|
+
)
|
|
1311
2343
|
|
|
1312
2344
|
|
|
1313
2345
|
def _compress_session_history(
|
|
@@ -1434,7 +2466,7 @@ def _sync_session_key_after_compress(
|
|
|
1434
2466
|
session["pending_title"] = None
|
|
1435
2467
|
if restart_slash_worker:
|
|
1436
2468
|
try:
|
|
1437
|
-
_restart_slash_worker(session)
|
|
2469
|
+
_restart_slash_worker(sid, session)
|
|
1438
2470
|
except Exception:
|
|
1439
2471
|
pass
|
|
1440
2472
|
|
|
@@ -1481,6 +2513,15 @@ def _get_usage(agent) -> dict:
|
|
|
1481
2513
|
usage["cost_usd"] = float(cost.amount_usd)
|
|
1482
2514
|
except Exception:
|
|
1483
2515
|
pass
|
|
2516
|
+
# Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on
|
|
2517
|
+
# HERMES_DEV_CREDITS so the payload stays clean when the flag is off.
|
|
2518
|
+
if is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")):
|
|
2519
|
+
try:
|
|
2520
|
+
spent = agent.get_credits_spent_micros()
|
|
2521
|
+
if spent is not None:
|
|
2522
|
+
usage["dev_credits_spent_micros"] = int(spent)
|
|
2523
|
+
except Exception:
|
|
2524
|
+
pass
|
|
1484
2525
|
return usage
|
|
1485
2526
|
|
|
1486
2527
|
|
|
@@ -1542,7 +2583,8 @@ def _current_profile_name() -> str:
|
|
|
1542
2583
|
# backend reporting less than its required value (or none at all — a pre-GUI
|
|
1543
2584
|
# checkout), surfacing a one-click "update to align" prompt instead of failing
|
|
1544
2585
|
# cryptically downstream. Bump whenever the desktop's backend contract changes.
|
|
1545
|
-
|
|
2586
|
+
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
|
|
2587
|
+
DESKTOP_BACKEND_CONTRACT = 2
|
|
1546
2588
|
|
|
1547
2589
|
|
|
1548
2590
|
def _session_info(agent, session: dict | None = None) -> dict:
|
|
@@ -1562,11 +2604,32 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|
|
1562
2604
|
):
|
|
1563
2605
|
reasoning_effort = str(reasoning_config.get("effort", "") or "")
|
|
1564
2606
|
service_tier = getattr(agent, "service_tier", None) or ""
|
|
2607
|
+
# Effective approval-bypass state — the same three sources that
|
|
2608
|
+
# check_all_command_guards() ORs together: persistent config
|
|
2609
|
+
# (approvals.mode=off), the process-scoped --yolo env, and the
|
|
2610
|
+
# per-session flag. Reporting only the per-session flag here would lie to
|
|
2611
|
+
# the desktop status bar (it would show YOLO "off" while approvals.mode=off
|
|
2612
|
+
# silently auto-approves every dangerous command).
|
|
2613
|
+
yolo = False
|
|
2614
|
+
try:
|
|
2615
|
+
from tools.approval import (
|
|
2616
|
+
_YOLO_MODE_FROZEN,
|
|
2617
|
+
_get_approval_mode,
|
|
2618
|
+
is_session_yolo_enabled,
|
|
2619
|
+
)
|
|
2620
|
+
|
|
2621
|
+
session_key = (session or {}).get("session_key")
|
|
2622
|
+
session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False
|
|
2623
|
+
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
|
|
2624
|
+
except Exception:
|
|
2625
|
+
yolo = False
|
|
1565
2626
|
info: dict = {
|
|
1566
2627
|
"model": getattr(agent, "model", ""),
|
|
2628
|
+
"provider": getattr(agent, "provider", ""),
|
|
1567
2629
|
"reasoning_effort": reasoning_effort,
|
|
1568
2630
|
"service_tier": service_tier,
|
|
1569
2631
|
"fast": service_tier == "priority",
|
|
2632
|
+
"yolo": yolo,
|
|
1570
2633
|
"tools": {},
|
|
1571
2634
|
"skills": {},
|
|
1572
2635
|
"cwd": cwd,
|
|
@@ -1637,8 +2700,15 @@ def _tool_ctx(name: str, args: dict) -> str:
|
|
|
1637
2700
|
return ""
|
|
1638
2701
|
|
|
1639
2702
|
|
|
1640
|
-
|
|
1641
|
-
|
|
2703
|
+
# Tool Args/Result text shipped to the TUI for the verbose trail line. The TUI
|
|
2704
|
+
# renders only a small persisted preview (ui-tui VERBOSE_TRAIL_MAX_CHARS), kept
|
|
2705
|
+
# all session and expanded by default — so shipping more than that is pure pipe
|
|
2706
|
+
# waste AND feeds the Ink render-tree blowup that silently OOM-killed the TUI
|
|
2707
|
+
# parent (#34095). Cap here to match the render budget (a hair more, so the
|
|
2708
|
+
# "[omitted …]" label is still informative when output is genuinely large).
|
|
2709
|
+
# Full output stays in the agent context and the SQLite session, untouched.
|
|
2710
|
+
_TUI_VERBOSE_TEXT_MAX_CHARS = 1_000
|
|
2711
|
+
_TUI_VERBOSE_TEXT_MAX_LINES = 16
|
|
1642
2712
|
|
|
1643
2713
|
|
|
1644
2714
|
def _cap_tui_verbose_text(text: str) -> str:
|
|
@@ -1860,6 +2930,8 @@ def _on_tool_progress(
|
|
|
1860
2930
|
payload["subagent_id"] = str(_kwargs["subagent_id"])
|
|
1861
2931
|
if _kwargs.get("parent_id"):
|
|
1862
2932
|
payload["parent_id"] = str(_kwargs["parent_id"])
|
|
2933
|
+
if _kwargs.get("child_session_id"):
|
|
2934
|
+
payload["child_session_id"] = str(_kwargs["child_session_id"])
|
|
1863
2935
|
if _kwargs.get("depth") is not None:
|
|
1864
2936
|
payload["depth"] = int(_kwargs["depth"])
|
|
1865
2937
|
if _kwargs.get("model"):
|
|
@@ -1905,7 +2977,103 @@ def _on_tool_progress(
|
|
|
1905
2977
|
if preview and event_type == "subagent.tool":
|
|
1906
2978
|
payload["tool_preview"] = str(preview)
|
|
1907
2979
|
payload["text"] = str(preview)
|
|
1908
|
-
|
|
2980
|
+
# subagent.text is the child's per-token reply, relayed solely to feed a
|
|
2981
|
+
# watch window's live mirror. It is meaningless on the parent session
|
|
2982
|
+
# (which shows the child via the spawn tree, not its reply body), so
|
|
2983
|
+
# skip the parent emit — sending hundreds of ignored token frames there
|
|
2984
|
+
# is wasted traffic and a trap for any future parent-side subagent
|
|
2985
|
+
# catch-all. The mirror keys off the child sid and is unaffected.
|
|
2986
|
+
if event_type != "subagent.text":
|
|
2987
|
+
_emit(event_type, sid, payload)
|
|
2988
|
+
_mirror_subagent_to_child(event_type, payload)
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
# ── Child-session live mirror ────────────────────────────────────────
|
|
2992
|
+
# A delegated child is not a live gateway session — it runs synchronously
|
|
2993
|
+
# inside the parent's turn, and its activity reaches the gateway only as
|
|
2994
|
+
# relayed ``subagent.*`` events on the PARENT sid. When a UI opens the child's
|
|
2995
|
+
# own session (session.resume on ``child_session_id``, e.g. the desktop's
|
|
2996
|
+
# open-in-new-window), that window would otherwise sit silent until the run
|
|
2997
|
+
# persists. Translate the relayed events into the native stream events the
|
|
2998
|
+
# window already renders — emitted on the CHILD sid, routed to its transport
|
|
2999
|
+
# by write_json — so the window shows a real midstream turn.
|
|
3000
|
+
_child_mirrors: dict[str, dict] = {}
|
|
3001
|
+
_child_mirrors_lock = threading.Lock()
|
|
3002
|
+
# Stored child session ids with a delegation run currently in flight (refreshed
|
|
3003
|
+
# on every relayed subagent.* event, popped on subagent.complete). Lets a lazy
|
|
3004
|
+
# watch resume report running=true so the window shows a busy indicator even
|
|
3005
|
+
# while the child is silent inside a long tool call (no events for 25s+).
|
|
3006
|
+
_active_child_runs: dict[str, float] = {}
|
|
3007
|
+
# Staleness bound for the registry: entries refresh on every relayed event, so
|
|
3008
|
+
# anything this quiet means the completion event was lost (callback raised,
|
|
3009
|
+
# parent crashed) — don't let a leaked entry pin "running" forever.
|
|
3010
|
+
_CHILD_RUN_STALE_S = 3600.0
|
|
3011
|
+
|
|
3012
|
+
|
|
3013
|
+
def _child_run_active(child_key: str) -> bool:
|
|
3014
|
+
ts = _active_child_runs.get(child_key)
|
|
3015
|
+
return ts is not None and (time.time() - ts) < _CHILD_RUN_STALE_S
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
|
|
3019
|
+
child_key = str(payload.get("child_session_id") or "")
|
|
3020
|
+
if not child_key:
|
|
3021
|
+
return
|
|
3022
|
+
# Liveness registry first — it must be accurate even when no window is
|
|
3023
|
+
# open, so a window opened mid-run can immediately know the child is busy.
|
|
3024
|
+
if event_type == "subagent.complete":
|
|
3025
|
+
_active_child_runs.pop(child_key, None)
|
|
3026
|
+
else:
|
|
3027
|
+
_active_child_runs[child_key] = time.time()
|
|
3028
|
+
# Mirror only into a live watch session (keyed by session_key; its live sid
|
|
3029
|
+
# differs from the stored id) that has NOT been upgraded to a full agent.
|
|
3030
|
+
# No window / closed → nothing to mirror; an upgraded session owns a real
|
|
3031
|
+
# native stream and mirroring on top would interleave two turns on one sid.
|
|
3032
|
+
# Either way drop state so a reopened window starts a fresh synthetic turn.
|
|
3033
|
+
live = _find_live_session_by_key(child_key)
|
|
3034
|
+
if live is None or live[1].get("agent") is not None:
|
|
3035
|
+
with _child_mirrors_lock:
|
|
3036
|
+
_child_mirrors.pop(child_key, None)
|
|
3037
|
+
return
|
|
3038
|
+
csid = live[0]
|
|
3039
|
+
with _child_mirrors_lock:
|
|
3040
|
+
st = _child_mirrors.setdefault(child_key, {"seq": 0, "open_tool": None, "started": False})
|
|
3041
|
+
if not st["started"]:
|
|
3042
|
+
st["started"] = True
|
|
3043
|
+
_emit("message.start", csid)
|
|
3044
|
+
if event_type == "subagent.thinking":
|
|
3045
|
+
if text := str(payload.get("text") or ""):
|
|
3046
|
+
_emit("reasoning.delta", csid, {"text": text})
|
|
3047
|
+
elif event_type == "subagent.text":
|
|
3048
|
+
# The child's streamed reply text — the actual "agent talking".
|
|
3049
|
+
# Relayed token-by-token from the child's run_conversation
|
|
3050
|
+
# stream_callback, so the watch window streams the reply live.
|
|
3051
|
+
if text := str(payload.get("text") or ""):
|
|
3052
|
+
_emit("message.delta", csid, {"text": text})
|
|
3053
|
+
elif event_type == "subagent.start":
|
|
3054
|
+
# One-time header line (the child's goal) so a freshly opened window
|
|
3055
|
+
# shows immediate context before the first reply token streams.
|
|
3056
|
+
if text := str(payload.get("text") or ""):
|
|
3057
|
+
_emit("message.delta", csid, {"text": f"{text}\n"})
|
|
3058
|
+
elif event_type == "subagent.tool":
|
|
3059
|
+
if st["open_tool"]:
|
|
3060
|
+
_emit("tool.complete", csid, st["open_tool"])
|
|
3061
|
+
st["seq"] += 1
|
|
3062
|
+
tool = {
|
|
3063
|
+
"name": str(payload.get("tool_name") or "tool"),
|
|
3064
|
+
"tool_id": f"submirror:{child_key}:{st['seq']}",
|
|
3065
|
+
"args": {},
|
|
3066
|
+
}
|
|
3067
|
+
if preview := str(payload.get("tool_preview") or payload.get("text") or ""):
|
|
3068
|
+
tool["preview"] = preview
|
|
3069
|
+
st["open_tool"] = tool
|
|
3070
|
+
_emit("tool.start", csid, tool)
|
|
3071
|
+
elif event_type == "subagent.complete":
|
|
3072
|
+
if st["open_tool"]:
|
|
3073
|
+
_emit("tool.complete", csid, st["open_tool"])
|
|
3074
|
+
summary = str(payload.get("summary") or payload.get("text") or "")
|
|
3075
|
+
_emit("message.complete", csid, {"text": summary})
|
|
3076
|
+
_child_mirrors.pop(child_key, None)
|
|
1909
3077
|
|
|
1910
3078
|
|
|
1911
3079
|
def _agent_cbs(sid: str) -> dict:
|
|
@@ -1930,9 +3098,35 @@ def _agent_cbs(sid: str) -> dict:
|
|
|
1930
3098
|
"status_callback": lambda kind, text=None: _status_update(
|
|
1931
3099
|
sid, str(kind), None if text is None else str(text)
|
|
1932
3100
|
),
|
|
3101
|
+
# Credits/notice spine (L1): an AgentNotice fired by the agent becomes a
|
|
3102
|
+
# notification.show WS event; a recovery clear becomes notification.clear.
|
|
3103
|
+
# Snake_case payload to match the existing gateway-event convention.
|
|
3104
|
+
"notice_callback": lambda n: _emit(
|
|
3105
|
+
"notification.show",
|
|
3106
|
+
sid,
|
|
3107
|
+
{
|
|
3108
|
+
"text": n.text,
|
|
3109
|
+
"level": n.level,
|
|
3110
|
+
"kind": n.kind,
|
|
3111
|
+
"ttl_ms": n.ttl_ms,
|
|
3112
|
+
"key": n.key,
|
|
3113
|
+
"id": n.id,
|
|
3114
|
+
},
|
|
3115
|
+
),
|
|
3116
|
+
"notice_clear_callback": lambda key: _emit(
|
|
3117
|
+
"notification.clear", sid, {"key": key}
|
|
3118
|
+
),
|
|
1933
3119
|
"clarify_callback": lambda q, c: _block(
|
|
1934
3120
|
"clarify.request", sid, {"question": q, "choices": c}
|
|
1935
3121
|
),
|
|
3122
|
+
# read_terminal tool (desktop GUI): same blocking bridge as clarify — the
|
|
3123
|
+
# renderer answers terminal.read.respond with the serialized buffer.
|
|
3124
|
+
"read_terminal_callback": lambda start=None, count=None: _block(
|
|
3125
|
+
"terminal.read.request",
|
|
3126
|
+
sid,
|
|
3127
|
+
{k: v for k, v in (("start", start), ("count", count)) if v is not None},
|
|
3128
|
+
timeout=30,
|
|
3129
|
+
),
|
|
1936
3130
|
}
|
|
1937
3131
|
|
|
1938
3132
|
|
|
@@ -2092,6 +3286,29 @@ def _parse_tui_skills_env() -> list[str]:
|
|
|
2092
3286
|
return skills
|
|
2093
3287
|
|
|
2094
3288
|
|
|
3289
|
+
def _load_fallback_model():
|
|
3290
|
+
"""Return the configured fallback chain for TUI-created agents.
|
|
3291
|
+
|
|
3292
|
+
Delegates to the shared ``get_fallback_chain`` helper so the TUI path
|
|
3293
|
+
stays in parity with ``HermesCLI.__init__`` and ``gateway/run.py``:
|
|
3294
|
+
``fallback_providers`` is the primary source of truth and keeps its
|
|
3295
|
+
order, with legacy ``fallback_model`` entries merged in afterwards
|
|
3296
|
+
(deduped on provider/model/base_url).
|
|
3297
|
+
"""
|
|
3298
|
+
from hermes_cli.fallback_config import get_fallback_chain
|
|
3299
|
+
|
|
3300
|
+
return get_fallback_chain(_load_cfg())
|
|
3301
|
+
|
|
3302
|
+
|
|
3303
|
+
def _agent_fallback_model(agent):
|
|
3304
|
+
"""Return an agent's fallback chain without rehydrating deliberately empty chains."""
|
|
3305
|
+
if hasattr(agent, "_fallback_chain"):
|
|
3306
|
+
return getattr(agent, "_fallback_chain") or []
|
|
3307
|
+
if hasattr(agent, "_fallback_model"):
|
|
3308
|
+
return getattr(agent, "_fallback_model", None)
|
|
3309
|
+
return _load_fallback_model()
|
|
3310
|
+
|
|
3311
|
+
|
|
2095
3312
|
def _background_agent_kwargs(agent, task_id: str) -> dict:
|
|
2096
3313
|
cfg = _load_cfg()
|
|
2097
3314
|
|
|
@@ -2126,7 +3343,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict:
|
|
|
2126
3343
|
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
|
|
2127
3344
|
"platform": "tui",
|
|
2128
3345
|
"session_db": _get_db(),
|
|
2129
|
-
"fallback_model":
|
|
3346
|
+
"fallback_model": _agent_fallback_model(agent),
|
|
2130
3347
|
}
|
|
2131
3348
|
|
|
2132
3349
|
|
|
@@ -2261,11 +3478,19 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
|
|
|
2261
3478
|
tokens = _set_session_context(session["session_key"])
|
|
2262
3479
|
try:
|
|
2263
3480
|
new_agent = _make_agent(
|
|
2264
|
-
sid,
|
|
3481
|
+
sid,
|
|
3482
|
+
session["session_key"],
|
|
3483
|
+
session_id=session["session_key"],
|
|
3484
|
+
# Preserve this session's chosen model across /new so a reset
|
|
3485
|
+
# doesn't silently revert to global config (or to a model another
|
|
3486
|
+
# session set). See the cross-session-contamination note in
|
|
3487
|
+
# _apply_model_switch.
|
|
3488
|
+
model_override=session.get("model_override"),
|
|
2265
3489
|
)
|
|
2266
3490
|
finally:
|
|
2267
3491
|
_clear_session_context(tokens)
|
|
2268
3492
|
session["agent"] = new_agent
|
|
3493
|
+
session["config_model_seen"] = _config_model_target()
|
|
2269
3494
|
session["attached_images"] = []
|
|
2270
3495
|
session["edit_snapshots"] = {}
|
|
2271
3496
|
session["image_counter"] = 0
|
|
@@ -2278,20 +3503,109 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
|
|
|
2278
3503
|
session["history_version"] = int(session.get("history_version", 0)) + 1
|
|
2279
3504
|
info = _session_info(new_agent, session)
|
|
2280
3505
|
_emit("session.info", sid, info)
|
|
2281
|
-
_restart_slash_worker(session)
|
|
3506
|
+
_restart_slash_worker(sid, session)
|
|
2282
3507
|
return info
|
|
2283
3508
|
|
|
2284
3509
|
|
|
2285
|
-
def
|
|
3510
|
+
def _schedule_mcp_late_refresh(sid: str, agent) -> None:
|
|
3511
|
+
"""Refresh a session's tool snapshot when MCP discovery lands late.
|
|
3512
|
+
|
|
3513
|
+
The agent snapshots ``agent.tools`` once at build time and never re-reads
|
|
3514
|
+
the registry (run_agent/agent_init). ``_make_agent`` briefly joins the
|
|
3515
|
+
background MCP discovery thread (``wait_for_mcp_discovery``, ~0.75s) so
|
|
3516
|
+
already-spawning servers land in that snapshot — but a server that takes
|
|
3517
|
+
longer than the bound to connect (common for an HTTP MCP server on first
|
|
3518
|
+
connect) lands *after* the agent is built. Its tools are then absent from
|
|
3519
|
+
both the agent and the banner for the whole session, even though the
|
|
3520
|
+
classic CLI shows them (the CLI re-derives ``get_tool_definitions`` at
|
|
3521
|
+
banner render time, which re-waits, so it picks them up).
|
|
3522
|
+
|
|
3523
|
+
This schedules an off-critical-path daemon that waits for discovery to
|
|
3524
|
+
finish, then rebuilds the snapshot and re-emits ``session.info`` so both
|
|
3525
|
+
the agent's callable tools and the banner count catch up — the same
|
|
3526
|
+
rebuild ``/reload-mcp`` performs, but automatic.
|
|
3527
|
+
|
|
3528
|
+
Cache safety: the rebuild only runs while the session is still pre-first-
|
|
3529
|
+
turn (no API call made yet → nothing cached to invalidate). If the user
|
|
3530
|
+
has already sent a message, we leave the snapshot frozen rather than
|
|
3531
|
+
invalidate the prompt cache mid-conversation — those late tools then
|
|
3532
|
+
require an explicit ``/reload-mcp`` (which gates on user consent), exactly
|
|
3533
|
+
as today. No-op when discovery already finished before the agent build.
|
|
3534
|
+
"""
|
|
3535
|
+
try:
|
|
3536
|
+
from tui_gateway.entry import mcp_discovery_in_flight, join_mcp_discovery
|
|
3537
|
+
except Exception:
|
|
3538
|
+
return
|
|
3539
|
+
if not mcp_discovery_in_flight():
|
|
3540
|
+
return
|
|
3541
|
+
|
|
3542
|
+
def _wait_then_refresh() -> None:
|
|
3543
|
+
# Bounded but generous — a server still not connected after this is
|
|
3544
|
+
# genuinely slow/dead; the user can /reload-mcp once it recovers.
|
|
3545
|
+
if not join_mcp_discovery(timeout=30.0):
|
|
3546
|
+
return
|
|
3547
|
+
with _sessions_lock:
|
|
3548
|
+
session = _sessions.get(sid)
|
|
3549
|
+
# Session may have been closed/reset while we waited.
|
|
3550
|
+
if session is None or session.get("agent") is not agent:
|
|
3551
|
+
return
|
|
3552
|
+
# Cache safety: never rebuild the tool list once the conversation
|
|
3553
|
+
# has started — that would invalidate the cached prompt prefix.
|
|
3554
|
+
if (
|
|
3555
|
+
int(getattr(agent, "_user_turn_count", 0) or 0) > 0
|
|
3556
|
+
or int(getattr(agent, "_api_call_count", 0) or 0) > 0
|
|
3557
|
+
):
|
|
3558
|
+
return
|
|
3559
|
+
try:
|
|
3560
|
+
from tools.mcp_tool import refresh_agent_mcp_tools
|
|
3561
|
+
|
|
3562
|
+
added = refresh_agent_mcp_tools(agent, quiet_mode=True)
|
|
3563
|
+
except Exception as exc:
|
|
3564
|
+
logger.warning(
|
|
3565
|
+
"Late MCP refresh: tool snapshot rebuild failed for %s: %s",
|
|
3566
|
+
sid,
|
|
3567
|
+
exc,
|
|
3568
|
+
)
|
|
3569
|
+
return
|
|
3570
|
+
# No new tools landed (discovery added nothing) → don't churn the client.
|
|
3571
|
+
if not added:
|
|
3572
|
+
return
|
|
3573
|
+
info = _session_info(agent, session)
|
|
3574
|
+
# Emit outside the lock — write_json must not block under _sessions_lock.
|
|
3575
|
+
_emit("session.info", sid, info)
|
|
3576
|
+
|
|
3577
|
+
threading.Thread(
|
|
3578
|
+
target=_wait_then_refresh,
|
|
3579
|
+
name=f"tui-mcp-late-refresh-{sid}",
|
|
3580
|
+
daemon=True,
|
|
3581
|
+
).start()
|
|
3582
|
+
|
|
3583
|
+
|
|
3584
|
+
def _make_agent(
|
|
3585
|
+
sid: str,
|
|
3586
|
+
key: str,
|
|
3587
|
+
session_id: str | None = None,
|
|
3588
|
+
session_db=None,
|
|
3589
|
+
model_override: dict | str | None = None,
|
|
3590
|
+
provider_override: str | None = None,
|
|
3591
|
+
reasoning_config_override: dict | None = None,
|
|
3592
|
+
service_tier_override: str | None = None,
|
|
3593
|
+
):
|
|
2286
3594
|
from run_agent import AIAgent
|
|
2287
3595
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
2288
3596
|
|
|
2289
3597
|
# MCP tool discovery runs in a background daemon thread at startup so a
|
|
2290
|
-
# dead server can't freeze the shell
|
|
2291
|
-
#
|
|
2292
|
-
#
|
|
2293
|
-
#
|
|
2294
|
-
#
|
|
3598
|
+
# dead server can't freeze the shell. The agent snapshots its tool list
|
|
3599
|
+
# once here and never re-reads it, so briefly wait for in-flight discovery
|
|
3600
|
+
# to land before building — bounded, so a slow/dead server still can't
|
|
3601
|
+
# block. Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps
|
|
3602
|
+
# its existing tui_gateway.entry-owned thread.
|
|
3603
|
+
try:
|
|
3604
|
+
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
|
3605
|
+
|
|
3606
|
+
wait_for_mcp_discovery()
|
|
3607
|
+
except Exception:
|
|
3608
|
+
pass
|
|
2295
3609
|
try:
|
|
2296
3610
|
from tui_gateway.entry import wait_for_mcp_discovery
|
|
2297
3611
|
|
|
@@ -2316,11 +3630,62 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
|
|
2316
3630
|
system_prompt = "\n\n".join(
|
|
2317
3631
|
part for part in (system_prompt, skills_prompt) if part
|
|
2318
3632
|
).strip()
|
|
2319
|
-
model
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
3633
|
+
# Prefer a per-session model override (set by a prior in-session /model
|
|
3634
|
+
# switch) over global config/env resolution. Resume-time stored sessions may
|
|
3635
|
+
# also pass scalar model/provider/runtime knobs from the persisted DB row.
|
|
3636
|
+
if isinstance(model_override, dict) and model_override.get("model"):
|
|
3637
|
+
model = str(model_override.get("model") or "")
|
|
3638
|
+
requested_provider = model_override.get("provider") or provider_override or None
|
|
3639
|
+
override_base_url = model_override.get("base_url")
|
|
3640
|
+
override_api_key = model_override.get("api_key")
|
|
3641
|
+
override_api_mode = model_override.get("api_mode")
|
|
3642
|
+
resolve_kwargs = {}
|
|
3643
|
+
if str(requested_provider or "").strip().lower() == "custom":
|
|
3644
|
+
# Session rows persisted before the custom-provider identity fix
|
|
3645
|
+
# (see _runtime_model_config) stored the resolved provider
|
|
3646
|
+
# "custom", which _get_named_custom_provider cannot match back to
|
|
3647
|
+
# a named ``providers:`` / ``custom_providers:`` entry — the
|
|
3648
|
+
# rebuild then either raised auth_unavailable, silently resolved
|
|
3649
|
+
# placeholder credentials against the patched-back base_url, or
|
|
3650
|
+
# (when no base_url was stored) routed to the OpenRouter default
|
|
3651
|
+
# with no key, surfacing as "No LLM provider configured". Recover
|
|
3652
|
+
# the entry identity from the persisted base_url, falling back to
|
|
3653
|
+
# the configured provider when the override carries no base_url
|
|
3654
|
+
# (the recurring Desktop/TUI regression vector).
|
|
3655
|
+
from hermes_cli.runtime_provider import canonical_custom_identity
|
|
3656
|
+
|
|
3657
|
+
recovered = canonical_custom_identity(base_url=override_base_url or None)
|
|
3658
|
+
if recovered:
|
|
3659
|
+
requested_provider = recovered
|
|
3660
|
+
if override_base_url:
|
|
3661
|
+
# Failing identity recovery, still hand the base_url to the
|
|
3662
|
+
# direct-alias branch so pool/env credentials resolve for it.
|
|
3663
|
+
resolve_kwargs["explicit_base_url"] = override_base_url
|
|
3664
|
+
runtime = resolve_runtime_provider(
|
|
3665
|
+
requested=requested_provider,
|
|
3666
|
+
target_model=model or None,
|
|
3667
|
+
**resolve_kwargs,
|
|
3668
|
+
)
|
|
3669
|
+
# The switch already resolved concrete credentials/endpoint; honor them
|
|
3670
|
+
# so a custom/named endpoint survives the rebuild even if global
|
|
3671
|
+
# resolution would pick a different one.
|
|
3672
|
+
if override_base_url:
|
|
3673
|
+
runtime["base_url"] = override_base_url
|
|
3674
|
+
if override_api_key:
|
|
3675
|
+
runtime["api_key"] = override_api_key
|
|
3676
|
+
if override_api_mode:
|
|
3677
|
+
runtime["api_mode"] = override_api_mode
|
|
3678
|
+
else:
|
|
3679
|
+
model, requested_provider = _resolve_startup_runtime()
|
|
3680
|
+
if isinstance(model_override, str) and model_override:
|
|
3681
|
+
model = model_override
|
|
3682
|
+
if provider_override:
|
|
3683
|
+
requested_provider = provider_override
|
|
3684
|
+
runtime = resolve_runtime_provider(
|
|
3685
|
+
requested=requested_provider,
|
|
3686
|
+
target_model=model or None,
|
|
3687
|
+
)
|
|
3688
|
+
_pr = _load_provider_routing()
|
|
2324
3689
|
return AIAgent(
|
|
2325
3690
|
model=model,
|
|
2326
3691
|
max_iterations=_cfg_max_turns(cfg, 90),
|
|
@@ -2337,51 +3702,84 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
|
|
2337
3702
|
# display detail). See cli.py PR (decoupling fix) for the matching
|
|
2338
3703
|
# change on the classic CLI side.
|
|
2339
3704
|
verbose_logging=False,
|
|
2340
|
-
reasoning_config=
|
|
2341
|
-
|
|
3705
|
+
reasoning_config=(
|
|
3706
|
+
reasoning_config_override
|
|
3707
|
+
if reasoning_config_override is not None
|
|
3708
|
+
else _load_reasoning_config()
|
|
3709
|
+
),
|
|
3710
|
+
service_tier=(
|
|
3711
|
+
service_tier_override
|
|
3712
|
+
if service_tier_override is not None
|
|
3713
|
+
else _load_service_tier()
|
|
3714
|
+
),
|
|
2342
3715
|
enabled_toolsets=_load_enabled_toolsets(),
|
|
3716
|
+
# OpenRouter provider-routing prefs (config.yaml `provider_routing`).
|
|
3717
|
+
# Mirrors the messaging gateway + CLI so the desktop/TUI honors the same
|
|
3718
|
+
# routing instead of letting OpenRouter pick providers at random.
|
|
3719
|
+
providers_allowed=_pr.get("only"),
|
|
3720
|
+
providers_ignored=_pr.get("ignore"),
|
|
3721
|
+
providers_order=_pr.get("order"),
|
|
3722
|
+
provider_sort=_pr.get("sort"),
|
|
3723
|
+
provider_require_parameters=_pr.get("require_parameters", False),
|
|
3724
|
+
provider_data_collection=_pr.get("data_collection"),
|
|
2343
3725
|
platform="tui",
|
|
2344
3726
|
session_id=session_id or key,
|
|
2345
|
-
session_db=_get_db(),
|
|
3727
|
+
session_db=session_db if session_db is not None else _get_db(),
|
|
2346
3728
|
ephemeral_system_prompt=system_prompt or None,
|
|
2347
3729
|
checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")),
|
|
2348
3730
|
pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
|
|
2349
3731
|
skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
|
|
2350
3732
|
skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
|
|
3733
|
+
fallback_model=_load_fallback_model(),
|
|
2351
3734
|
**_agent_cbs(sid),
|
|
2352
3735
|
)
|
|
2353
3736
|
|
|
2354
3737
|
|
|
2355
|
-
def _init_session(
|
|
3738
|
+
def _init_session(
|
|
3739
|
+
sid: str,
|
|
3740
|
+
key: str,
|
|
3741
|
+
agent,
|
|
3742
|
+
history: list,
|
|
3743
|
+
cols: int = 80,
|
|
3744
|
+
cwd: str | None = None,
|
|
3745
|
+
session_db=None,
|
|
3746
|
+
):
|
|
2356
3747
|
now = time.time()
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
3748
|
+
with _sessions_lock:
|
|
3749
|
+
_sessions[sid] = {
|
|
3750
|
+
"agent": agent,
|
|
3751
|
+
"session_key": key,
|
|
3752
|
+
"history": history,
|
|
3753
|
+
"history_lock": threading.Lock(),
|
|
3754
|
+
"history_version": 0,
|
|
3755
|
+
"inflight_turn": None,
|
|
3756
|
+
"created_at": now,
|
|
3757
|
+
"last_active": now,
|
|
3758
|
+
"running": False,
|
|
3759
|
+
"attached_images": [],
|
|
3760
|
+
"image_counter": 0,
|
|
3761
|
+
"cwd": cwd or _completion_cwd(),
|
|
3762
|
+
"cols": cols,
|
|
3763
|
+
"slash_worker": None,
|
|
3764
|
+
"show_reasoning": _load_show_reasoning(),
|
|
3765
|
+
"tool_progress_mode": _load_tool_progress_mode(),
|
|
3766
|
+
"edit_snapshots": {},
|
|
3767
|
+
"tool_started_at": {},
|
|
3768
|
+
# Per-session model override set by an in-session /model switch.
|
|
3769
|
+
# Honored on rebuild (/new, resume) so a switch in THIS session
|
|
3770
|
+
# never leaks into siblings via process-global env vars.
|
|
3771
|
+
"model_override": None,
|
|
3772
|
+
# Pin async event emissions to whichever transport created the
|
|
3773
|
+
# session (stdio for Ink, JSON-RPC WS for the dashboard sidebar).
|
|
3774
|
+
"transport": current_transport() or _stdio_transport,
|
|
3775
|
+
}
|
|
3776
|
+
db = session_db if session_db is not None else _get_db()
|
|
2381
3777
|
if db is not None:
|
|
2382
3778
|
row = db.get_session(key)
|
|
2383
3779
|
if row and row.get("cwd"):
|
|
2384
|
-
|
|
3780
|
+
with _sessions_lock:
|
|
3781
|
+
if sid in _sessions:
|
|
3782
|
+
_sessions[sid]["cwd"] = row["cwd"]
|
|
2385
3783
|
else:
|
|
2386
3784
|
try:
|
|
2387
3785
|
db.update_session_cwd(key, _sessions[sid]["cwd"])
|
|
@@ -2389,8 +3787,10 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|
|
2389
3787
|
logger.debug("failed to persist resumed session cwd", exc_info=True)
|
|
2390
3788
|
_register_session_cwd(_sessions[sid])
|
|
2391
3789
|
try:
|
|
2392
|
-
|
|
2393
|
-
|
|
3790
|
+
_attach_worker(
|
|
3791
|
+
sid,
|
|
3792
|
+
_sessions[sid],
|
|
3793
|
+
_SlashWorker(key, getattr(agent, "model", _resolve_model())),
|
|
2394
3794
|
)
|
|
2395
3795
|
except Exception:
|
|
2396
3796
|
# Defer hard-failure to slash.exec; chat still works without slash worker.
|
|
@@ -2411,14 +3811,21 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|
|
2411
3811
|
agent.background_review_callback = lambda message, _sid=sid: _emit(
|
|
2412
3812
|
"review.summary", _sid, {"text": str(message)}
|
|
2413
3813
|
)
|
|
3814
|
+
# Honor display.memory_notifications (off | on | verbose) like the
|
|
3815
|
+
# messaging gateway and CLI do — otherwise the review always behaved as
|
|
3816
|
+
# "on" on the TUI/desktop and a user who set "off" was ignored.
|
|
3817
|
+
agent.memory_notifications = _load_memory_notifications()
|
|
2414
3818
|
except Exception:
|
|
2415
3819
|
# Bare AIAgents that don't expose the attribute (unlikely, but keep
|
|
2416
3820
|
# session startup resilient).
|
|
2417
3821
|
pass
|
|
2418
3822
|
_wire_callbacks(sid)
|
|
2419
|
-
|
|
3823
|
+
with _sessions_lock:
|
|
3824
|
+
if sid in _sessions:
|
|
3825
|
+
_sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
|
|
2420
3826
|
_notify_session_boundary("on_session_reset", key)
|
|
2421
|
-
_emit("session.info", sid, _session_info(agent, _sessions
|
|
3827
|
+
_emit("session.info", sid, _session_info(agent, _sessions.get(sid, {})))
|
|
3828
|
+
_schedule_mcp_late_refresh(sid, agent)
|
|
2422
3829
|
|
|
2423
3830
|
|
|
2424
3831
|
def _new_session_key() -> str:
|
|
@@ -2626,16 +4033,27 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
|
|
|
2626
4033
|
{"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
|
2627
4034
|
)
|
|
2628
4035
|
continue
|
|
2629
|
-
|
|
4036
|
+
# An assistant turn may carry only reasoning/thinking content with no
|
|
4037
|
+
# visible text (extended-thinking turns, thinking-only recovery
|
|
4038
|
+
# responses). Such a turn is persisted with its reasoning fields and is
|
|
4039
|
+
# recallable from the transcript, but dropping it here as "empty" makes
|
|
4040
|
+
# it vanish from the resumed/reloaded session view while the desktop's
|
|
4041
|
+
# reasoning disclosure has nothing to render. Keep it when it carries
|
|
4042
|
+
# reasoning so the "Thinking…" block still shows. (#44022)
|
|
4043
|
+
reasoning_keys = (
|
|
4044
|
+
"reasoning",
|
|
4045
|
+
"reasoning_content",
|
|
4046
|
+
"reasoning_details",
|
|
4047
|
+
"codex_reasoning_items",
|
|
4048
|
+
)
|
|
4049
|
+
has_reasoning = role == "assistant" and any(
|
|
4050
|
+
m.get(key) for key in reasoning_keys
|
|
4051
|
+
)
|
|
4052
|
+
if not content_text.strip() and not has_reasoning:
|
|
2630
4053
|
continue
|
|
2631
4054
|
msg = {"role": role, "text": content_text}
|
|
2632
4055
|
if role == "assistant":
|
|
2633
|
-
for key in
|
|
2634
|
-
"reasoning",
|
|
2635
|
-
"reasoning_content",
|
|
2636
|
-
"reasoning_details",
|
|
2637
|
-
"codex_reasoning_items",
|
|
2638
|
-
):
|
|
4056
|
+
for key in reasoning_keys:
|
|
2639
4057
|
if key in m and m.get(key) is not None:
|
|
2640
4058
|
msg[key] = m.get(key)
|
|
2641
4059
|
messages.append(msg)
|
|
@@ -2764,37 +4182,78 @@ def _(rid, params: dict) -> dict:
|
|
|
2764
4182
|
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
|
|
2765
4183
|
except Exception:
|
|
2766
4184
|
explicit_cwd = False
|
|
4185
|
+
resolved_cwd = _completion_cwd(params)
|
|
2767
4186
|
_enable_gateway_prompts()
|
|
2768
4187
|
|
|
4188
|
+
# ``profile`` (app-global remote mode): a new chat started under a non-launch
|
|
4189
|
+
# profile must build its agent + persist against THAT profile's home/state.db,
|
|
4190
|
+
# not the dashboard's launch profile. Stored on the session so _start_agent_build
|
|
4191
|
+
# and each turn re-bind HERMES_HOME. None/own profile → launch (unchanged).
|
|
4192
|
+
profile = (params.get("profile") or "").strip() or None
|
|
4193
|
+
profile_home = _profile_home(profile)
|
|
4194
|
+
|
|
4195
|
+
# The desktop composer owns its model/effort/fast as plain UI state and ships
|
|
4196
|
+
# it on every session.create. Honor each as a PER-SESSION override (built into
|
|
4197
|
+
# the agent below) — never a global config write, so picking a model/effort
|
|
4198
|
+
# for a new chat can't mutate the profile default. provider is optional
|
|
4199
|
+
# (resolved at build).
|
|
4200
|
+
create_model = str(params.get("model") or "").strip()
|
|
4201
|
+
session_model_override = (
|
|
4202
|
+
{"model": create_model, "provider": str(params.get("provider") or "").strip() or None}
|
|
4203
|
+
if create_model
|
|
4204
|
+
else None
|
|
4205
|
+
)
|
|
4206
|
+
create_reasoning_override = None
|
|
4207
|
+
if effort := str(params.get("reasoning_effort") or "").strip():
|
|
4208
|
+
try:
|
|
4209
|
+
from hermes_constants import parse_reasoning_effort
|
|
4210
|
+
|
|
4211
|
+
create_reasoning_override = parse_reasoning_effort(effort)
|
|
4212
|
+
except Exception:
|
|
4213
|
+
create_reasoning_override = None
|
|
4214
|
+
# Only pin "fast" when explicitly requested; leaving it None lets the build
|
|
4215
|
+
# fall back to the profile default service tier rather than forcing normal.
|
|
4216
|
+
create_service_tier_override = "priority" if params.get("fast") else None
|
|
4217
|
+
|
|
2769
4218
|
ready = threading.Event()
|
|
2770
4219
|
now = time.time()
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
4220
|
+
lease, limit_message = _claim_active_session_slot(key, live_session_id=sid)
|
|
4221
|
+
if limit_message is not None:
|
|
4222
|
+
return _err(rid, 4090, limit_message)
|
|
4223
|
+
|
|
4224
|
+
with _sessions_lock:
|
|
4225
|
+
_sessions[sid] = {
|
|
4226
|
+
"agent": None,
|
|
4227
|
+
"agent_error": None,
|
|
4228
|
+
"agent_ready": ready,
|
|
4229
|
+
"attached_images": [],
|
|
4230
|
+
"close_on_disconnect": is_truthy_value(params.get("close_on_disconnect", False)),
|
|
4231
|
+
"active_session_lease": lease,
|
|
4232
|
+
"cols": cols,
|
|
4233
|
+
"created_at": now,
|
|
4234
|
+
"edit_snapshots": {},
|
|
4235
|
+
"explicit_cwd": explicit_cwd,
|
|
4236
|
+
"history": history,
|
|
4237
|
+
"history_lock": threading.Lock(),
|
|
4238
|
+
"history_version": 0,
|
|
4239
|
+
"image_counter": 0,
|
|
4240
|
+
"cwd": resolved_cwd,
|
|
4241
|
+
"inflight_turn": None,
|
|
4242
|
+
"last_active": now,
|
|
4243
|
+
"model_override": session_model_override,
|
|
4244
|
+
"create_reasoning_override": create_reasoning_override,
|
|
4245
|
+
"create_service_tier_override": create_service_tier_override,
|
|
4246
|
+
"pending_title": title or None,
|
|
4247
|
+
"profile_home": str(profile_home) if profile_home is not None else None,
|
|
4248
|
+
"running": False,
|
|
4249
|
+
"session_key": key,
|
|
4250
|
+
"show_reasoning": _load_show_reasoning(),
|
|
4251
|
+
"slash_worker": None,
|
|
4252
|
+
"tool_progress_mode": _load_tool_progress_mode(),
|
|
4253
|
+
"tool_started_at": {},
|
|
4254
|
+
"transport": current_transport() or _stdio_transport,
|
|
4255
|
+
}
|
|
4256
|
+
_register_session_cwd(_sessions[sid])
|
|
2798
4257
|
# NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop
|
|
2799
4258
|
# launch (and every "New agent" / draft) opens a session here just to paint
|
|
2800
4259
|
# the composer, so eagerly creating a row left an "Untitled" empty session
|
|
@@ -2823,7 +4282,20 @@ def _(rid, params: dict) -> dict:
|
|
|
2823
4282
|
"message_count": len(history),
|
|
2824
4283
|
"messages": _history_to_messages(history),
|
|
2825
4284
|
"info": {
|
|
2826
|
-
|
|
4285
|
+
# Reflect the per-session model override (desktop composer pick)
|
|
4286
|
+
# in the immediate response so the client doesn't briefly clobber
|
|
4287
|
+
# its sticky pick with the global default before the deferred
|
|
4288
|
+
# build's session.info lands.
|
|
4289
|
+
"model": (
|
|
4290
|
+
session_model_override.get("model")
|
|
4291
|
+
if session_model_override
|
|
4292
|
+
else _resolve_model()
|
|
4293
|
+
),
|
|
4294
|
+
**(
|
|
4295
|
+
{"provider": session_model_override["provider"]}
|
|
4296
|
+
if session_model_override and session_model_override.get("provider")
|
|
4297
|
+
else {}
|
|
4298
|
+
),
|
|
2827
4299
|
"tools": {},
|
|
2828
4300
|
"skills": {},
|
|
2829
4301
|
"cwd": _sessions[sid]["cwd"],
|
|
@@ -2931,33 +4403,296 @@ def _(rid, params: dict) -> dict:
|
|
|
2931
4403
|
target = params.get("session_id", "")
|
|
2932
4404
|
if not target:
|
|
2933
4405
|
return _err(rid, 4006, "session_id required")
|
|
2934
|
-
|
|
4406
|
+
try:
|
|
4407
|
+
cols = int(params.get("cols", 80))
|
|
4408
|
+
except (TypeError, ValueError):
|
|
4409
|
+
cols = 80
|
|
4410
|
+
# ``profile`` (app-global remote mode): resume a session that lives in another
|
|
4411
|
+
# local profile's state.db. None/own profile → the launch profile (unchanged).
|
|
4412
|
+
profile = (params.get("profile") or "").strip() or None
|
|
4413
|
+
profile_home = _profile_home(profile)
|
|
4414
|
+
|
|
4415
|
+
# In a profile scope, the agent OWNS a long-lived db handle bound to that
|
|
4416
|
+
# profile (do NOT auto-close it here). Otherwise reuse the shared launch db.
|
|
4417
|
+
if profile_home is not None:
|
|
4418
|
+
from hermes_state import SessionDB
|
|
4419
|
+
|
|
4420
|
+
db = SessionDB(db_path=profile_home / "state.db")
|
|
4421
|
+
else:
|
|
4422
|
+
db = _get_db()
|
|
2935
4423
|
if db is None:
|
|
2936
4424
|
return _db_unavailable_error(rid, code=5000)
|
|
4425
|
+
|
|
2937
4426
|
found = db.get_session(target)
|
|
2938
4427
|
if not found:
|
|
2939
4428
|
found = db.get_session_by_title(target)
|
|
2940
4429
|
if found:
|
|
2941
4430
|
target = found["id"]
|
|
4431
|
+
elif is_truthy_value(params.get("lazy", False)) and _child_run_active(target):
|
|
4432
|
+
# Race: a watch window opened on a freshly-spawned subagent. The
|
|
4433
|
+
# child relays `subagent.start` (which carries child_session_id and
|
|
4434
|
+
# triggers the window) BEFORE its first run_conversation() flushes
|
|
4435
|
+
# the DB row via _ensure_db_session, so db.get_session(target) is
|
|
4436
|
+
# momentarily empty. On slower hosts (notably WSL2, where SQLite +
|
|
4437
|
+
# process scheduling widen the gap) the window's resume consistently
|
|
4438
|
+
# lands inside this window and used to hard-fail "session not found"
|
|
4439
|
+
# — the frontend then 404'd on the REST messages fallback and the
|
|
4440
|
+
# window spun forever. The child is provably live (_child_run_active),
|
|
4441
|
+
# so proceed into the lazy branch with empty history; the live mirror
|
|
4442
|
+
# streams the whole turn anyway and the row exists by upgrade time.
|
|
4443
|
+
found = {}
|
|
2942
4444
|
else:
|
|
2943
4445
|
return _err(rid, 4007, "session not found")
|
|
4446
|
+
|
|
4447
|
+
# Follow the compression-continuation chain to the live tip so a resume on
|
|
4448
|
+
# a rotated-out parent id binds to the descendant that actually holds the
|
|
4449
|
+
# post-compression turns. Auto-compression ends the session and forks a
|
|
4450
|
+
# continuation child; without this, resuming the original id (the desktop's
|
|
4451
|
+
# routed id when the chat was opened before it rotated) reloads the parent
|
|
4452
|
+
# transcript and the response generated after compression is missing — the
|
|
4453
|
+
# "I came back and the reply isn't there" bug on large sessions. Resolving
|
|
4454
|
+
# here also re-anchors the fast path below so a still-live rotated session
|
|
4455
|
+
# is reused (by its new key) instead of rebuilding a duplicate agent on the
|
|
4456
|
+
# stale parent. Skipped for lazy watch windows, which intentionally attach
|
|
4457
|
+
# to the exact child branch they were opened on.
|
|
4458
|
+
if found and not is_truthy_value(params.get("lazy", False)):
|
|
4459
|
+
try:
|
|
4460
|
+
tip = db.resolve_resume_session_id(target)
|
|
4461
|
+
except Exception:
|
|
4462
|
+
tip = target
|
|
4463
|
+
if tip and tip != target:
|
|
4464
|
+
target = tip
|
|
4465
|
+
found = db.get_session(target) or found
|
|
4466
|
+
|
|
4467
|
+
profile_resume_cwd = str(found.get("cwd") or "").strip() or _profile_configured_cwd(
|
|
4468
|
+
profile_home
|
|
4469
|
+
)
|
|
4470
|
+
|
|
4471
|
+
def _reuse_live_payload(sid: str, session: dict) -> dict:
|
|
4472
|
+
payload = _live_session_payload(
|
|
4473
|
+
sid,
|
|
4474
|
+
session,
|
|
4475
|
+
cols=cols,
|
|
4476
|
+
touch=True,
|
|
4477
|
+
transport=current_transport() or _stdio_transport,
|
|
4478
|
+
)
|
|
4479
|
+
payload["resumed"] = target
|
|
4480
|
+
# A lazy watch session never owns a run loop, so its payload's running
|
|
4481
|
+
# flag is always False — overlay the child-run registry so a reconnecting
|
|
4482
|
+
# watch window keeps its busy indicator while the child is still mid-run.
|
|
4483
|
+
if session.get("agent") is None and _child_run_active(target):
|
|
4484
|
+
payload["running"] = True
|
|
4485
|
+
payload["status"] = "streaming"
|
|
4486
|
+
return payload
|
|
4487
|
+
|
|
4488
|
+
# Fast path: if the session is already live, reuse it under the lock.
|
|
4489
|
+
with _session_resume_lock:
|
|
4490
|
+
live = _find_live_session_by_key(target)
|
|
4491
|
+
if live is not None:
|
|
4492
|
+
return _ok(rid, _reuse_live_payload(*live))
|
|
4493
|
+
|
|
4494
|
+
# Lazy/watch resume: register the live session WITHOUT building an agent.
|
|
4495
|
+
# Used by the desktop's subagent windows — the child runs inside the
|
|
4496
|
+
# parent's turn, so its window only needs the stored history plus a
|
|
4497
|
+
# transport for the child-mirror's live events. Skipping _make_agent here
|
|
4498
|
+
# is what keeps the window cheap while the backend is busy running the
|
|
4499
|
+
# delegation. A later prompt.submit upgrades it via _start_agent_build
|
|
4500
|
+
# (resume_session_id keeps the upgrade on the stored conversation).
|
|
4501
|
+
if is_truthy_value(params.get("lazy", False)):
|
|
4502
|
+
sid = uuid.uuid4().hex[:8]
|
|
4503
|
+
lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)
|
|
4504
|
+
if limit_message is not None:
|
|
4505
|
+
return _err(rid, 4090, limit_message)
|
|
4506
|
+
try:
|
|
4507
|
+
db.reopen_session(target)
|
|
4508
|
+
# The child's OWN conversation only. Delegation children are
|
|
4509
|
+
# parent-linked rows, so include_ancestors would prepend the
|
|
4510
|
+
# parent's entire transcript — a watch window opened on a subagent
|
|
4511
|
+
# must show the subagent's branch, not the parent's prompt.
|
|
4512
|
+
history = db.get_messages_as_conversation(target)
|
|
4513
|
+
except Exception as e:
|
|
4514
|
+
if lease is not None:
|
|
4515
|
+
lease.release()
|
|
4516
|
+
return _err(rid, 5000, f"resume failed: {e}")
|
|
4517
|
+
messages = _history_to_messages(history)
|
|
4518
|
+
cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd())
|
|
4519
|
+
now = time.time()
|
|
4520
|
+
# A delegated child mid-run emits no native session events of its own —
|
|
4521
|
+
# report its liveness from the relay registry so the window paints a
|
|
4522
|
+
# busy indicator instead of a dead idle transcript.
|
|
4523
|
+
child_running = _child_run_active(target)
|
|
4524
|
+
with _session_resume_lock:
|
|
4525
|
+
live = _find_live_session_by_key(target)
|
|
4526
|
+
if live is not None:
|
|
4527
|
+
if lease is not None:
|
|
4528
|
+
lease.release()
|
|
4529
|
+
return _ok(rid, _reuse_live_payload(*live))
|
|
4530
|
+
with _sessions_lock:
|
|
4531
|
+
_sessions[sid] = {
|
|
4532
|
+
"agent": None,
|
|
4533
|
+
"agent_error": None,
|
|
4534
|
+
"agent_ready": threading.Event(),
|
|
4535
|
+
"attached_images": [],
|
|
4536
|
+
"close_on_disconnect": is_truthy_value(
|
|
4537
|
+
params.get("close_on_disconnect", False)
|
|
4538
|
+
),
|
|
4539
|
+
"active_session_lease": lease,
|
|
4540
|
+
"cols": cols,
|
|
4541
|
+
"created_at": now,
|
|
4542
|
+
"display_history_prefix": [],
|
|
4543
|
+
"edit_snapshots": {},
|
|
4544
|
+
"explicit_cwd": False,
|
|
4545
|
+
"history": history,
|
|
4546
|
+
"history_lock": threading.Lock(),
|
|
4547
|
+
"history_version": 0,
|
|
4548
|
+
"image_counter": 0,
|
|
4549
|
+
"cwd": cwd,
|
|
4550
|
+
"inflight_turn": None,
|
|
4551
|
+
"last_active": now,
|
|
4552
|
+
"lazy": True,
|
|
4553
|
+
"pending_title": None,
|
|
4554
|
+
"profile_home": str(profile_home) if profile_home is not None else None,
|
|
4555
|
+
"resume_session_id": target,
|
|
4556
|
+
"running": False,
|
|
4557
|
+
"session_key": target,
|
|
4558
|
+
"show_reasoning": _load_show_reasoning(),
|
|
4559
|
+
"slash_worker": None,
|
|
4560
|
+
"tool_progress_mode": _load_tool_progress_mode(),
|
|
4561
|
+
"tool_started_at": {},
|
|
4562
|
+
"transport": current_transport() or _stdio_transport,
|
|
4563
|
+
}
|
|
4564
|
+
_register_session_cwd(_sessions[sid])
|
|
4565
|
+
return _ok(
|
|
4566
|
+
rid,
|
|
4567
|
+
{
|
|
4568
|
+
"session_id": sid,
|
|
4569
|
+
"resumed": target,
|
|
4570
|
+
"message_count": len(messages),
|
|
4571
|
+
"messages": messages,
|
|
4572
|
+
"info": {
|
|
4573
|
+
"cwd": cwd,
|
|
4574
|
+
"branch": _git_branch_for_cwd(cwd),
|
|
4575
|
+
"model": _resolve_model(),
|
|
4576
|
+
"tools": {},
|
|
4577
|
+
"skills": {},
|
|
4578
|
+
"lazy": True,
|
|
4579
|
+
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
|
4580
|
+
"profile_name": _current_profile_name(),
|
|
4581
|
+
},
|
|
4582
|
+
"inflight": None,
|
|
4583
|
+
"running": child_running,
|
|
4584
|
+
"session_key": target,
|
|
4585
|
+
"started_at": now,
|
|
4586
|
+
"status": "streaming" if child_running else "idle",
|
|
4587
|
+
},
|
|
4588
|
+
)
|
|
4589
|
+
|
|
4590
|
+
# Build the agent OUTSIDE the lock — _make_agent can block for seconds
|
|
4591
|
+
# (MCP discovery, prompt/skill build, AIAgent construction). Holding
|
|
4592
|
+
# _session_resume_lock across it would stall session.close on the main
|
|
4593
|
+
# dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs.
|
|
2944
4594
|
sid = uuid.uuid4().hex[:8]
|
|
4595
|
+
lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)
|
|
4596
|
+
if limit_message is not None:
|
|
4597
|
+
return _err(rid, 4090, limit_message)
|
|
2945
4598
|
_enable_gateway_prompts()
|
|
4599
|
+
home_token = (
|
|
4600
|
+
set_hermes_home_override(str(profile_home)) if profile_home is not None else None
|
|
4601
|
+
)
|
|
2946
4602
|
try:
|
|
2947
4603
|
db.reopen_session(target)
|
|
2948
4604
|
history = db.get_messages_as_conversation(target)
|
|
2949
4605
|
display_history = db.get_messages_as_conversation(
|
|
2950
4606
|
target, include_ancestors=True
|
|
2951
4607
|
)
|
|
4608
|
+
display_history_prefix = display_history[
|
|
4609
|
+
: max(0, len(display_history) - len(history))
|
|
4610
|
+
]
|
|
2952
4611
|
messages = _history_to_messages(display_history)
|
|
2953
4612
|
tokens = _set_session_context(target)
|
|
2954
4613
|
try:
|
|
2955
|
-
agent
|
|
4614
|
+
# Pass the profile's db so the agent persists turns to the right
|
|
4615
|
+
# state.db; home override is active here so config/skills/model
|
|
4616
|
+
# resolve to the profile too. Runtime identity is restored from the
|
|
4617
|
+
# stored session row so switching chats does not inherit whatever
|
|
4618
|
+
# global model another chat last selected.
|
|
4619
|
+
stored_runtime_overrides = _stored_session_runtime_overrides(found)
|
|
4620
|
+
agent = _make_agent(
|
|
4621
|
+
sid,
|
|
4622
|
+
target,
|
|
4623
|
+
session_id=target,
|
|
4624
|
+
session_db=db,
|
|
4625
|
+
**stored_runtime_overrides,
|
|
4626
|
+
)
|
|
2956
4627
|
finally:
|
|
2957
4628
|
_clear_session_context(tokens)
|
|
2958
|
-
_init_session(sid, target, agent, history, cols=int(params.get("cols", 80)))
|
|
2959
4629
|
except Exception as e:
|
|
4630
|
+
if lease is not None:
|
|
4631
|
+
lease.release()
|
|
2960
4632
|
return _err(rid, 5000, f"resume failed: {e}")
|
|
4633
|
+
finally:
|
|
4634
|
+
if home_token is not None:
|
|
4635
|
+
reset_hermes_home_override(home_token)
|
|
4636
|
+
|
|
4637
|
+
# Double-checked locking: another concurrent resume may have created the
|
|
4638
|
+
# live session while we were building. Re-check under the lock; if it won,
|
|
4639
|
+
# discard our just-built agent and reuse theirs (no worker/poller wired yet).
|
|
4640
|
+
with _session_resume_lock:
|
|
4641
|
+
live = _find_live_session_by_key(target)
|
|
4642
|
+
if live is not None:
|
|
4643
|
+
try:
|
|
4644
|
+
if hasattr(agent, "close"):
|
|
4645
|
+
agent.close()
|
|
4646
|
+
except Exception:
|
|
4647
|
+
pass
|
|
4648
|
+
if lease is not None:
|
|
4649
|
+
lease.release()
|
|
4650
|
+
other_sid, other_session = live
|
|
4651
|
+
payload = _live_session_payload(
|
|
4652
|
+
other_sid,
|
|
4653
|
+
other_session,
|
|
4654
|
+
cols=cols,
|
|
4655
|
+
touch=True,
|
|
4656
|
+
transport=current_transport() or _stdio_transport,
|
|
4657
|
+
)
|
|
4658
|
+
payload["resumed"] = target
|
|
4659
|
+
return _ok(rid, payload)
|
|
4660
|
+
try:
|
|
4661
|
+
init_home_token = (
|
|
4662
|
+
set_hermes_home_override(str(profile_home))
|
|
4663
|
+
if profile_home is not None
|
|
4664
|
+
else None
|
|
4665
|
+
)
|
|
4666
|
+
try:
|
|
4667
|
+
_init_session(
|
|
4668
|
+
sid,
|
|
4669
|
+
target,
|
|
4670
|
+
agent,
|
|
4671
|
+
history,
|
|
4672
|
+
cols=cols,
|
|
4673
|
+
cwd=profile_resume_cwd,
|
|
4674
|
+
session_db=db,
|
|
4675
|
+
)
|
|
4676
|
+
finally:
|
|
4677
|
+
if init_home_token is not None:
|
|
4678
|
+
reset_hermes_home_override(init_home_token)
|
|
4679
|
+
if sid in _sessions:
|
|
4680
|
+
if stored_runtime_overrides.get("model_override") is not None:
|
|
4681
|
+
_sessions[sid]["model_override"] = stored_runtime_overrides[
|
|
4682
|
+
"model_override"
|
|
4683
|
+
]
|
|
4684
|
+
_sessions[sid]["display_history_prefix"] = display_history_prefix
|
|
4685
|
+
# Remember the profile home so each turn re-binds HERMES_HOME (the
|
|
4686
|
+
# agent persists to its own db, but mid-turn home reads — memory,
|
|
4687
|
+
# skills — must resolve to the resumed profile too).
|
|
4688
|
+
if profile_home is not None:
|
|
4689
|
+
_sessions[sid]["profile_home"] = str(profile_home)
|
|
4690
|
+
_sessions[sid]["active_session_lease"] = lease
|
|
4691
|
+
except Exception as e:
|
|
4692
|
+
if lease is not None:
|
|
4693
|
+
lease.release()
|
|
4694
|
+
return _err(rid, 5000, f"resume failed: {e}")
|
|
4695
|
+
session = _sessions.get(sid) or {}
|
|
2961
4696
|
return _ok(
|
|
2962
4697
|
rid,
|
|
2963
4698
|
{
|
|
@@ -2965,7 +4700,12 @@ def _(rid, params: dict) -> dict:
|
|
|
2965
4700
|
"resumed": target,
|
|
2966
4701
|
"message_count": len(messages),
|
|
2967
4702
|
"messages": messages,
|
|
2968
|
-
"info": _session_info(agent,
|
|
4703
|
+
"info": _session_info(agent, session),
|
|
4704
|
+
"inflight": None,
|
|
4705
|
+
"running": False,
|
|
4706
|
+
"session_key": target,
|
|
4707
|
+
"started_at": float(session.get("created_at") or time.time()),
|
|
4708
|
+
"status": "idle",
|
|
2969
4709
|
},
|
|
2970
4710
|
)
|
|
2971
4711
|
|
|
@@ -3007,7 +4747,9 @@ def _session_live_status(sid: str, session: dict) -> str:
|
|
|
3007
4747
|
if _session_pending_kind(sid):
|
|
3008
4748
|
return "waiting"
|
|
3009
4749
|
ready = session.get("agent_ready")
|
|
3010
|
-
|
|
4750
|
+
# Unset + build never started = a lazy watch session sitting idle, not a
|
|
4751
|
+
# session stuck mid-construction.
|
|
4752
|
+
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
|
|
3011
4753
|
return "starting"
|
|
3012
4754
|
if session.get("running"):
|
|
3013
4755
|
return "working"
|
|
@@ -3058,6 +4800,15 @@ def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
|
|
|
3058
4800
|
}
|
|
3059
4801
|
|
|
3060
4802
|
|
|
4803
|
+
def _find_live_session_by_key(session_key: str) -> tuple[str, dict] | None:
|
|
4804
|
+
for sid, session in list(_sessions.items()):
|
|
4805
|
+
if session.get("_finalized"):
|
|
4806
|
+
continue
|
|
4807
|
+
if str(session.get("session_key") or "") == session_key:
|
|
4808
|
+
return sid, session
|
|
4809
|
+
return None
|
|
4810
|
+
|
|
4811
|
+
|
|
3061
4812
|
def _fallback_session_info(session: dict) -> dict:
|
|
3062
4813
|
agent = session.get("agent")
|
|
3063
4814
|
if agent is not None:
|
|
@@ -3071,6 +4822,41 @@ def _fallback_session_info(session: dict) -> dict:
|
|
|
3071
4822
|
}
|
|
3072
4823
|
|
|
3073
4824
|
|
|
4825
|
+
def _live_session_payload(
|
|
4826
|
+
sid: str,
|
|
4827
|
+
session: dict,
|
|
4828
|
+
*,
|
|
4829
|
+
cols: int | None = None,
|
|
4830
|
+
touch: bool = False,
|
|
4831
|
+
transport: Transport | None = None,
|
|
4832
|
+
) -> dict:
|
|
4833
|
+
with session["history_lock"]:
|
|
4834
|
+
if cols is not None:
|
|
4835
|
+
session["cols"] = cols
|
|
4836
|
+
if transport is not None:
|
|
4837
|
+
session["transport"] = transport
|
|
4838
|
+
if touch:
|
|
4839
|
+
session["last_active"] = time.time()
|
|
4840
|
+
history = list(session.get("display_history_prefix") or []) + list(
|
|
4841
|
+
session.get("history") or []
|
|
4842
|
+
)
|
|
4843
|
+
inflight = _inflight_snapshot(session)
|
|
4844
|
+
running = bool(session.get("running"))
|
|
4845
|
+
payload = {
|
|
4846
|
+
"info": _fallback_session_info(session),
|
|
4847
|
+
"message_count": len(history),
|
|
4848
|
+
"messages": _history_to_messages(history),
|
|
4849
|
+
"running": running,
|
|
4850
|
+
"session_id": sid,
|
|
4851
|
+
"session_key": session.get("session_key") or sid,
|
|
4852
|
+
"started_at": float(session.get("created_at") or time.time()),
|
|
4853
|
+
"status": _session_live_status(sid, session),
|
|
4854
|
+
}
|
|
4855
|
+
if inflight:
|
|
4856
|
+
payload["inflight"] = inflight
|
|
4857
|
+
return payload
|
|
4858
|
+
|
|
4859
|
+
|
|
3074
4860
|
@method("session.active_list")
|
|
3075
4861
|
def _(rid, params: dict) -> dict:
|
|
3076
4862
|
"""Return live TUI sessions in this gateway process.
|
|
@@ -3081,14 +4867,31 @@ def _(rid, params: dict) -> dict:
|
|
|
3081
4867
|
"""
|
|
3082
4868
|
current = str(params.get("current_session_id") or "")
|
|
3083
4869
|
try:
|
|
3084
|
-
|
|
4870
|
+
with _sessions_lock:
|
|
4871
|
+
snapshot = list(_sessions.items())
|
|
3085
4872
|
except Exception as e:
|
|
3086
4873
|
return _err(rid, 5036, f"could not enumerate active sessions: {e}")
|
|
3087
4874
|
|
|
4875
|
+
# Liveness filter (#38950): a session whose teardown has begun (``_finalized``)
|
|
4876
|
+
# is dead — its agent/worker are being released and it is no longer
|
|
4877
|
+
# attachable — but it can briefly remain in ``_sessions`` until the reaper
|
|
4878
|
+
# pops it (the WS grace-reap and idle reaper both set ``_finalized`` inside
|
|
4879
|
+
# ``_teardown_session`` before the pop). Counting these inflated the footer's
|
|
4880
|
+
# "N sessions" count, which only ever went up until a gateway restart. Drop
|
|
4881
|
+
# them here so the count reflects genuinely attachable sessions. We do NOT
|
|
4882
|
+
# filter on ``transport is _detached_ws_transport`` (the WS-detached drop
|
|
4883
|
+
# sentinel): a detached session is still attachable via a quick reconnect /
|
|
4884
|
+
# session.resume until the grace-reap finalizes it, and a standalone
|
|
4885
|
+
# ``hermes --tui`` session legitimately rides the real stdio transport and
|
|
4886
|
+
# must stay visible.
|
|
3088
4887
|
# Keep the natural creation/insertion order from ``_sessions``. The
|
|
3089
4888
|
# frontend marks the focused session with ``current``; it should not jump to
|
|
3090
4889
|
# the top just because the user switched to it.
|
|
3091
|
-
rows = [
|
|
4890
|
+
rows = [
|
|
4891
|
+
_session_live_item(sid, session, current)
|
|
4892
|
+
for sid, session in snapshot
|
|
4893
|
+
if not session.get("_finalized")
|
|
4894
|
+
]
|
|
3092
4895
|
return _ok(rid, {"sessions": rows})
|
|
3093
4896
|
|
|
3094
4897
|
|
|
@@ -3103,28 +4906,16 @@ def _(rid, params: dict) -> dict:
|
|
|
3103
4906
|
session, err = _sess_nowait({"session_id": sid}, rid)
|
|
3104
4907
|
if err:
|
|
3105
4908
|
return err
|
|
4909
|
+
assert session is not None
|
|
3106
4910
|
|
|
3107
|
-
with session["history_lock"]:
|
|
3108
|
-
session["last_active"] = time.time()
|
|
3109
|
-
history = list(session.get("display_history") or session.get("history") or [])
|
|
3110
|
-
inflight = _inflight_snapshot(session)
|
|
3111
|
-
running = bool(session.get("running"))
|
|
3112
|
-
status = _session_live_status(sid, session)
|
|
3113
|
-
payload = {
|
|
3114
|
-
"info": _fallback_session_info(session),
|
|
3115
|
-
"message_count": len(history),
|
|
3116
|
-
"messages": _history_to_messages(history),
|
|
3117
|
-
"running": running,
|
|
3118
|
-
"session_id": sid,
|
|
3119
|
-
"session_key": session.get("session_key") or sid,
|
|
3120
|
-
"started_at": float(session.get("created_at") or time.time()),
|
|
3121
|
-
"status": status,
|
|
3122
|
-
}
|
|
3123
|
-
if inflight:
|
|
3124
|
-
payload["inflight"] = inflight
|
|
3125
4911
|
return _ok(
|
|
3126
4912
|
rid,
|
|
3127
|
-
|
|
4913
|
+
_live_session_payload(
|
|
4914
|
+
sid,
|
|
4915
|
+
session,
|
|
4916
|
+
touch=True,
|
|
4917
|
+
transport=current_transport() or _stdio_transport,
|
|
4918
|
+
),
|
|
3128
4919
|
)
|
|
3129
4920
|
|
|
3130
4921
|
|
|
@@ -3153,7 +4944,8 @@ def _(rid, params: dict) -> dict:
|
|
|
3153
4944
|
# dictionary changed size during iteration``. If even the snapshot
|
|
3154
4945
|
# raises, fail closed (refuse the delete) rather than fail open.
|
|
3155
4946
|
try:
|
|
3156
|
-
|
|
4947
|
+
with _sessions_lock:
|
|
4948
|
+
snapshot = list(_sessions.values())
|
|
3157
4949
|
except Exception as e:
|
|
3158
4950
|
return _err(rid, 5036, f"could not enumerate active sessions: {e}")
|
|
3159
4951
|
active = {s.get("session_key") for s in snapshot if s.get("session_key")}
|
|
@@ -3213,7 +5005,6 @@ def _(rid, params: dict) -> dict:
|
|
|
3213
5005
|
session["pending_title"] = None
|
|
3214
5006
|
return _ok(rid, {"pending": False, "title": title})
|
|
3215
5007
|
# rowcount == 0 can mean "same value" as well as "missing row".
|
|
3216
|
-
# Queue only when the session row truly does not exist yet.
|
|
3217
5008
|
existing_row = db.get_session(key)
|
|
3218
5009
|
if existing_row:
|
|
3219
5010
|
session["pending_title"] = None
|
|
@@ -3224,6 +5015,23 @@ def _(rid, params: dict) -> dict:
|
|
|
3224
5015
|
"title": (existing_row.get("title") or title),
|
|
3225
5016
|
},
|
|
3226
5017
|
)
|
|
5018
|
+
# No row yet (the DB write is deferred to the first prompt so empty
|
|
5019
|
+
# drafts don't litter the sidebar). An explicit /title is clear user
|
|
5020
|
+
# intent, not an abandoned draft — so persist the row NOW and set the
|
|
5021
|
+
# title, mirroring the messaging gateway's _handle_title_command. The
|
|
5022
|
+
# old behavior only queued pending_title and relied on the post-turn
|
|
5023
|
+
# apply block; if that turn never landed under this session_key the
|
|
5024
|
+
# title was silently lost and the sidebar fell back to the message
|
|
5025
|
+
# preview. Creating the row up front removes that race entirely. The
|
|
5026
|
+
# min-messages sidebar filter keeps a titled 0-message row hidden, so
|
|
5027
|
+
# a /title'd-but-never-used draft still doesn't clutter the list.
|
|
5028
|
+
_ensure_session_db_row(session)
|
|
5029
|
+
with _session_db(session) as scoped_db:
|
|
5030
|
+
if scoped_db is not None and scoped_db.set_session_title(key, title):
|
|
5031
|
+
session["pending_title"] = None
|
|
5032
|
+
return _ok(rid, {"pending": False, "title": title})
|
|
5033
|
+
# Row creation didn't take (DB unavailable, or a concurrent writer) —
|
|
5034
|
+
# fall back to queuing so the post-turn apply block can still recover.
|
|
3227
5035
|
session["pending_title"] = title
|
|
3228
5036
|
return _ok(rid, {"pending": True, "title": title})
|
|
3229
5037
|
except ValueError as e:
|
|
@@ -3232,21 +5040,416 @@ def _(rid, params: dict) -> dict:
|
|
|
3232
5040
|
return _err(rid, 5007, str(e))
|
|
3233
5041
|
|
|
3234
5042
|
|
|
3235
|
-
@method("
|
|
5043
|
+
@method("handoff.request")
|
|
3236
5044
|
def _(rid, params: dict) -> dict:
|
|
5045
|
+
"""Queue a handoff of this session to a messaging platform.
|
|
5046
|
+
|
|
5047
|
+
Desktop parity with the CLI ``/handoff`` command: we only write
|
|
5048
|
+
``handoff_state='pending'`` onto the persisted session row. The actual
|
|
5049
|
+
transfer is performed by the separate ``hermes gateway`` process, whose
|
|
5050
|
+
``_handoff_watcher`` claims the row, re-binds the session to the platform's
|
|
5051
|
+
home channel, and forges a synthetic turn. The desktop then polls
|
|
5052
|
+
``handoff.state`` for the terminal result.
|
|
5053
|
+
"""
|
|
3237
5054
|
session, err = _sess_nowait(params, rid)
|
|
3238
5055
|
if err:
|
|
3239
5056
|
return err
|
|
3240
|
-
|
|
5057
|
+
if session.get("running"):
|
|
5058
|
+
return _err(
|
|
5059
|
+
rid,
|
|
5060
|
+
4009,
|
|
5061
|
+
"session busy — wait for the current turn to finish, then retry the handoff",
|
|
5062
|
+
)
|
|
5063
|
+
|
|
5064
|
+
platform_name = (params.get("platform", "") or "").strip().lower()
|
|
5065
|
+
if not platform_name:
|
|
5066
|
+
return _err(rid, 4023, "platform required")
|
|
5067
|
+
|
|
5068
|
+
# Validate against the live gateway config — an unconfigured platform or a
|
|
5069
|
+
# missing home channel would leave the handoff pending forever, so reject
|
|
5070
|
+
# up front with a clear, actionable message (mirrors cli.py).
|
|
5071
|
+
try:
|
|
5072
|
+
from gateway.config import Platform, load_gateway_config
|
|
5073
|
+
except Exception as e: # pragma: no cover — gateway pkg always ships
|
|
5074
|
+
return _err(rid, 5021, f"could not load gateway config: {e}")
|
|
5075
|
+
try:
|
|
5076
|
+
platform = Platform(platform_name)
|
|
5077
|
+
except (ValueError, KeyError):
|
|
5078
|
+
return _err(rid, 4024, f"unknown platform '{platform_name}'")
|
|
5079
|
+
try:
|
|
5080
|
+
gw_config = load_gateway_config()
|
|
5081
|
+
except Exception as e:
|
|
5082
|
+
return _err(rid, 5021, f"could not load gateway config: {e}")
|
|
5083
|
+
pcfg = gw_config.platforms.get(platform)
|
|
5084
|
+
if not pcfg or not pcfg.enabled:
|
|
5085
|
+
return _err(
|
|
5086
|
+
rid,
|
|
5087
|
+
4025,
|
|
5088
|
+
f"platform '{platform_name}' is not configured/enabled in the gateway",
|
|
5089
|
+
)
|
|
5090
|
+
home = gw_config.get_home_channel(platform)
|
|
5091
|
+
if not home or not home.chat_id:
|
|
5092
|
+
return _err(
|
|
5093
|
+
rid,
|
|
5094
|
+
4026,
|
|
5095
|
+
f"no home channel configured for {platform_name} — set one with "
|
|
5096
|
+
"/sethome on the destination chat first",
|
|
5097
|
+
)
|
|
5098
|
+
|
|
5099
|
+
# The watcher transfers a persisted DB row, so make sure one exists even
|
|
5100
|
+
# for a brand-new empty chat (mirrors the CLI's set_session_title stub).
|
|
5101
|
+
_ensure_session_db_row(session)
|
|
5102
|
+
|
|
5103
|
+
with _session_db(session) as db:
|
|
5104
|
+
if db is None:
|
|
5105
|
+
return _db_unavailable_error(rid, code=5007)
|
|
5106
|
+
key = session["session_key"]
|
|
5107
|
+
try:
|
|
5108
|
+
if not db.get_session(key):
|
|
5109
|
+
db.set_session_title(key, f"handoff-{key[:8]}")
|
|
5110
|
+
ok = db.request_handoff(key, platform_name)
|
|
5111
|
+
except Exception as e:
|
|
5112
|
+
return _err(rid, 5007, str(e))
|
|
5113
|
+
|
|
5114
|
+
if not ok:
|
|
5115
|
+
return _err(
|
|
5116
|
+
rid,
|
|
5117
|
+
4027,
|
|
5118
|
+
"session is already in flight for handoff — wait for it to settle, then retry",
|
|
5119
|
+
)
|
|
3241
5120
|
return _ok(
|
|
3242
5121
|
rid,
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
5122
|
+
{
|
|
5123
|
+
"queued": True,
|
|
5124
|
+
"session_key": key,
|
|
5125
|
+
"platform": platform_name,
|
|
5126
|
+
"home_name": home.name,
|
|
5127
|
+
},
|
|
5128
|
+
)
|
|
5129
|
+
|
|
5130
|
+
|
|
5131
|
+
@method("handoff.state")
|
|
5132
|
+
def _(rid, params: dict) -> dict:
|
|
5133
|
+
"""Poll the handoff state for a session.
|
|
5134
|
+
|
|
5135
|
+
Returns ``{state, platform, error}`` where ``state`` is one of
|
|
5136
|
+
``pending|running|completed|failed`` (or empty when no handoff record
|
|
5137
|
+
exists). Desktop polls this after ``handoff.request``.
|
|
5138
|
+
"""
|
|
5139
|
+
session, err = _sess_nowait(params, rid)
|
|
5140
|
+
if err:
|
|
5141
|
+
return err
|
|
5142
|
+
with _session_db(session) as db:
|
|
5143
|
+
if db is None:
|
|
5144
|
+
return _db_unavailable_error(rid, code=5007)
|
|
5145
|
+
record = db.get_handoff_state(session["session_key"])
|
|
5146
|
+
|
|
5147
|
+
record = record or {}
|
|
5148
|
+
return _ok(
|
|
5149
|
+
rid,
|
|
5150
|
+
{
|
|
5151
|
+
"state": record.get("state") or "",
|
|
5152
|
+
"platform": record.get("platform") or "",
|
|
5153
|
+
"error": record.get("error") or "",
|
|
5154
|
+
},
|
|
5155
|
+
)
|
|
5156
|
+
|
|
5157
|
+
|
|
5158
|
+
@method("handoff.fail")
|
|
5159
|
+
def _(rid, params: dict) -> dict:
|
|
5160
|
+
"""Mark an in-flight handoff as failed so the user can retry.
|
|
5161
|
+
|
|
5162
|
+
Desktop calls this when its bounded poll times out. Only pending/running
|
|
5163
|
+
rows are changed so a late success from the gateway watcher is not clobbered.
|
|
5164
|
+
"""
|
|
5165
|
+
session, err = _sess_nowait(params, rid)
|
|
5166
|
+
if err:
|
|
5167
|
+
return err
|
|
5168
|
+
reason = str(params.get("error") or "handoff failed").strip()[:500]
|
|
5169
|
+
with _session_db(session) as db:
|
|
5170
|
+
if db is None:
|
|
5171
|
+
return _db_unavailable_error(rid, code=5007)
|
|
5172
|
+
key = session["session_key"]
|
|
5173
|
+
record = db.get_handoff_state(key) or {}
|
|
5174
|
+
state = record.get("state") or ""
|
|
5175
|
+
if state in {"pending", "running"}:
|
|
5176
|
+
db.fail_handoff(key, reason)
|
|
5177
|
+
return _ok(rid, {"failed": True, "state": "failed"})
|
|
5178
|
+
|
|
5179
|
+
return _ok(rid, {"failed": False, "state": state})
|
|
5180
|
+
|
|
5181
|
+
|
|
5182
|
+
@method("session.usage")
|
|
5183
|
+
def _(rid, params: dict) -> dict:
|
|
5184
|
+
session, err = _sess_nowait(params, rid)
|
|
5185
|
+
if err:
|
|
5186
|
+
return err
|
|
5187
|
+
agent = session.get("agent")
|
|
5188
|
+
usage: dict = (
|
|
5189
|
+
_get_usage(agent)
|
|
5190
|
+
if agent is not None
|
|
5191
|
+
else {"calls": 0, "input": 0, "output": 0, "total": 0}
|
|
5192
|
+
)
|
|
5193
|
+
# Nous credits block — agent-independent (a portal fetch), so it shows even
|
|
5194
|
+
# with zero API calls or on a resumed session. The TUI /usage panel renders
|
|
5195
|
+
# these lines regardless of `calls`. Fail-open: [] when not logged into Nous
|
|
5196
|
+
# or on any portal hiccup.
|
|
5197
|
+
try:
|
|
5198
|
+
from agent.account_usage import nous_credits_lines
|
|
5199
|
+
|
|
5200
|
+
credits = nous_credits_lines()
|
|
5201
|
+
if credits:
|
|
5202
|
+
usage["credits_lines"] = credits
|
|
5203
|
+
except Exception:
|
|
5204
|
+
pass
|
|
5205
|
+
return _ok(rid, usage)
|
|
5206
|
+
|
|
5207
|
+
|
|
5208
|
+
@method("credits.view")
|
|
5209
|
+
def _(rid, params: dict) -> dict:
|
|
5210
|
+
"""Structured Nous credit view for the TUI /credits command.
|
|
5211
|
+
|
|
5212
|
+
Account-independent (a portal fetch gated on "a Nous account is logged in"),
|
|
5213
|
+
so it works with no live agent / on a resumed session — same as the /usage
|
|
5214
|
+
credits block. Returns the surface-agnostic CreditsView fields so the TUI can
|
|
5215
|
+
render a clickable top-up <Link>. Fail-open: a portal hiccup or logged-out
|
|
5216
|
+
account yields {logged_in: false}, never an error the user has to parse.
|
|
5217
|
+
"""
|
|
5218
|
+
try:
|
|
5219
|
+
from agent.account_usage import build_credits_view
|
|
5220
|
+
|
|
5221
|
+
view = build_credits_view()
|
|
5222
|
+
return _ok(
|
|
5223
|
+
rid,
|
|
5224
|
+
{
|
|
5225
|
+
"logged_in": bool(view.logged_in),
|
|
5226
|
+
"balance_lines": [
|
|
5227
|
+
line for line in view.balance_lines if not line.lstrip().startswith("📈")
|
|
5228
|
+
],
|
|
5229
|
+
"identity_line": view.identity_line,
|
|
5230
|
+
"topup_url": view.topup_url,
|
|
5231
|
+
"depleted": bool(view.depleted),
|
|
5232
|
+
},
|
|
5233
|
+
)
|
|
5234
|
+
except Exception:
|
|
5235
|
+
# Fail-open: TUI treats this as "not logged in" and shows the prompt.
|
|
5236
|
+
return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False})
|
|
5237
|
+
|
|
5238
|
+
|
|
5239
|
+
# ===========================================================================
|
|
5240
|
+
# Phase 2b terminal billing RPC methods
|
|
5241
|
+
# ===========================================================================
|
|
5242
|
+
#
|
|
5243
|
+
# These return STRUCTURED success envelopes (result.ok / result.error) rather
|
|
5244
|
+
# than JSON-RPC-level errors, so the TUI's rpc() promise always resolves and the
|
|
5245
|
+
# Ink side can branch on the typed billing error code (insufficient_scope,
|
|
5246
|
+
# rate_limited, no_payment_method, …) to render the right affordance instead of
|
|
5247
|
+
# landing in a generic catch. The data-building lives in the shared core
|
|
5248
|
+
# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /credits.
|
|
5249
|
+
|
|
5250
|
+
|
|
5251
|
+
def _serialize_billing_error(exc) -> dict:
|
|
5252
|
+
"""Map a BillingError into the result.error envelope the TUI branches on."""
|
|
5253
|
+
from hermes_cli.nous_billing import (
|
|
5254
|
+
BillingRateLimited,
|
|
5255
|
+
BillingScopeRequired,
|
|
3248
5256
|
)
|
|
3249
5257
|
|
|
5258
|
+
kind = "error"
|
|
5259
|
+
if isinstance(exc, BillingScopeRequired):
|
|
5260
|
+
kind = "insufficient_scope"
|
|
5261
|
+
elif isinstance(exc, BillingRateLimited):
|
|
5262
|
+
kind = "rate_limited"
|
|
5263
|
+
elif getattr(exc, "error", None):
|
|
5264
|
+
kind = str(exc.error)
|
|
5265
|
+
return {
|
|
5266
|
+
"ok": False,
|
|
5267
|
+
"error": kind,
|
|
5268
|
+
"message": str(exc),
|
|
5269
|
+
"portal_url": getattr(exc, "portal_url", None),
|
|
5270
|
+
"retry_after": getattr(exc, "retry_after", None),
|
|
5271
|
+
"payload": getattr(exc, "payload", {}) or {},
|
|
5272
|
+
}
|
|
5273
|
+
|
|
5274
|
+
|
|
5275
|
+
def _serialize_billing_state(state) -> dict:
|
|
5276
|
+
"""Serialize a BillingState for the wire (Decimals → strings, money-safe)."""
|
|
5277
|
+
from agent.billing_view import format_money
|
|
5278
|
+
|
|
5279
|
+
def _s(value):
|
|
5280
|
+
return None if value is None else str(value)
|
|
5281
|
+
|
|
5282
|
+
card = None
|
|
5283
|
+
if state.card is not None:
|
|
5284
|
+
card = {"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked}
|
|
5285
|
+
monthly_cap = None
|
|
5286
|
+
if state.monthly_cap is not None:
|
|
5287
|
+
mc = state.monthly_cap
|
|
5288
|
+
monthly_cap = {
|
|
5289
|
+
"limit_usd": _s(mc.limit_usd),
|
|
5290
|
+
"limit_display": format_money(mc.limit_usd),
|
|
5291
|
+
"spent_this_month_usd": _s(mc.spent_this_month_usd),
|
|
5292
|
+
"spent_display": format_money(mc.spent_this_month_usd),
|
|
5293
|
+
"is_default_ceiling": mc.is_default_ceiling,
|
|
5294
|
+
}
|
|
5295
|
+
auto_reload = None
|
|
5296
|
+
if state.auto_reload is not None:
|
|
5297
|
+
ar = state.auto_reload
|
|
5298
|
+
auto_reload = {
|
|
5299
|
+
"enabled": ar.enabled,
|
|
5300
|
+
"threshold_usd": _s(ar.threshold_usd),
|
|
5301
|
+
"threshold_display": format_money(ar.threshold_usd),
|
|
5302
|
+
"reload_to_usd": _s(ar.reload_to_usd),
|
|
5303
|
+
"reload_to_display": format_money(ar.reload_to_usd),
|
|
5304
|
+
}
|
|
5305
|
+
return {
|
|
5306
|
+
"ok": True,
|
|
5307
|
+
"logged_in": state.logged_in,
|
|
5308
|
+
"org_name": state.org_name,
|
|
5309
|
+
"org_slug": state.org_slug,
|
|
5310
|
+
"role": state.role,
|
|
5311
|
+
"is_admin": state.is_admin,
|
|
5312
|
+
"can_charge": state.can_charge,
|
|
5313
|
+
"balance_usd": _s(state.balance_usd),
|
|
5314
|
+
"balance_display": format_money(state.balance_usd),
|
|
5315
|
+
"cli_billing_enabled": state.cli_billing_enabled,
|
|
5316
|
+
"charge_presets": [_s(p) for p in state.charge_presets],
|
|
5317
|
+
"charge_presets_display": [format_money(p) for p in state.charge_presets],
|
|
5318
|
+
"min_usd": _s(state.min_usd),
|
|
5319
|
+
"max_usd": _s(state.max_usd),
|
|
5320
|
+
"card": card,
|
|
5321
|
+
"monthly_cap": monthly_cap,
|
|
5322
|
+
"auto_reload": auto_reload,
|
|
5323
|
+
"portal_url": state.portal_url,
|
|
5324
|
+
"error": state.error,
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
|
|
5328
|
+
@method("billing.state")
|
|
5329
|
+
def _(rid, params: dict) -> dict:
|
|
5330
|
+
"""GET /api/billing/state → serialized BillingState (Screen 1 + 5).
|
|
5331
|
+
|
|
5332
|
+
Fail-open like credits.view: a logged-out / unreachable portal yields
|
|
5333
|
+
{ok:true, logged_in:false}. No scope required for this endpoint.
|
|
5334
|
+
"""
|
|
5335
|
+
try:
|
|
5336
|
+
from agent.billing_view import build_billing_state
|
|
5337
|
+
|
|
5338
|
+
state = build_billing_state()
|
|
5339
|
+
return _ok(rid, _serialize_billing_state(state))
|
|
5340
|
+
except Exception:
|
|
5341
|
+
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})
|
|
5342
|
+
|
|
5343
|
+
|
|
5344
|
+
@method("billing.charge")
|
|
5345
|
+
def _(rid, params: dict) -> dict:
|
|
5346
|
+
"""POST /api/billing/charge → {ok, chargeId} or a typed error envelope.
|
|
5347
|
+
|
|
5348
|
+
params: {amount_usd: str|number, idempotency_key?: str}. If no key is
|
|
5349
|
+
supplied, the server-side core mints a fresh one and returns it so the TUI can
|
|
5350
|
+
reuse it on retry of the SAME purchase.
|
|
5351
|
+
"""
|
|
5352
|
+
from hermes_cli.nous_billing import BillingError, post_charge
|
|
5353
|
+
from agent.billing_view import new_idempotency_key
|
|
5354
|
+
|
|
5355
|
+
amount = params.get("amount_usd")
|
|
5356
|
+
if amount is None:
|
|
5357
|
+
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "amount_usd is required"})
|
|
5358
|
+
key = params.get("idempotency_key") or new_idempotency_key()
|
|
5359
|
+
try:
|
|
5360
|
+
result = post_charge(amount_usd=amount, idempotency_key=key)
|
|
5361
|
+
return _ok(rid, {"ok": True, "charge_id": result.get("chargeId"), "idempotency_key": key})
|
|
5362
|
+
except BillingError as exc:
|
|
5363
|
+
env = _serialize_billing_error(exc)
|
|
5364
|
+
env["idempotency_key"] = key # so the TUI can reuse on retry
|
|
5365
|
+
return _ok(rid, env)
|
|
5366
|
+
except Exception as exc:
|
|
5367
|
+
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key})
|
|
5368
|
+
|
|
5369
|
+
|
|
5370
|
+
@method("billing.charge_status")
|
|
5371
|
+
def _(rid, params: dict) -> dict:
|
|
5372
|
+
"""GET /api/billing/charge/{id} → {ok, status, ...} or typed error.
|
|
5373
|
+
|
|
5374
|
+
The poll. Caller drives the 2s/5-min cadence; this is a single status read.
|
|
5375
|
+
"""
|
|
5376
|
+
from hermes_cli.nous_billing import BillingError, get_charge_status
|
|
5377
|
+
|
|
5378
|
+
charge_id = params.get("charge_id")
|
|
5379
|
+
if not charge_id:
|
|
5380
|
+
return _ok(rid, {"ok": False, "error": "invalid_charge_id", "message": "charge_id is required"})
|
|
5381
|
+
try:
|
|
5382
|
+
result = get_charge_status(charge_id)
|
|
5383
|
+
return _ok(
|
|
5384
|
+
rid,
|
|
5385
|
+
{
|
|
5386
|
+
"ok": True,
|
|
5387
|
+
"status": result.get("status"),
|
|
5388
|
+
"amount_usd": result.get("amountUsd"),
|
|
5389
|
+
"settled_at": result.get("settledAt"),
|
|
5390
|
+
"reason": result.get("reason"),
|
|
5391
|
+
},
|
|
5392
|
+
)
|
|
5393
|
+
except BillingError as exc:
|
|
5394
|
+
return _ok(rid, _serialize_billing_error(exc))
|
|
5395
|
+
except Exception as exc:
|
|
5396
|
+
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
5397
|
+
|
|
5398
|
+
|
|
5399
|
+
@method("billing.auto_reload")
|
|
5400
|
+
def _(rid, params: dict) -> dict:
|
|
5401
|
+
"""PATCH /api/billing/auto-top-up → {ok:true} or typed error (Screen 2).
|
|
5402
|
+
|
|
5403
|
+
params: {enabled: bool, threshold: number, top_up_amount: number}.
|
|
5404
|
+
"""
|
|
5405
|
+
from hermes_cli.nous_billing import BillingError, patch_auto_top_up
|
|
5406
|
+
|
|
5407
|
+
try:
|
|
5408
|
+
enabled = bool(params.get("enabled"))
|
|
5409
|
+
threshold = params.get("threshold")
|
|
5410
|
+
top_up_amount = params.get("top_up_amount")
|
|
5411
|
+
if threshold is None or top_up_amount is None:
|
|
5412
|
+
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "threshold and top_up_amount are required"})
|
|
5413
|
+
patch_auto_top_up(enabled=enabled, threshold=threshold, top_up_amount=top_up_amount)
|
|
5414
|
+
return _ok(rid, {"ok": True})
|
|
5415
|
+
except BillingError as exc:
|
|
5416
|
+
return _ok(rid, _serialize_billing_error(exc))
|
|
5417
|
+
except Exception as exc:
|
|
5418
|
+
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
|
5419
|
+
|
|
5420
|
+
|
|
5421
|
+
@method("billing.step_up")
|
|
5422
|
+
def _(rid, params: dict) -> dict:
|
|
5423
|
+
"""Run the lazy billing:manage step-up device flow → {ok, granted}.
|
|
5424
|
+
|
|
5425
|
+
Triggered by the TUI after a billing call returns error=insufficient_scope.
|
|
5426
|
+
Returns granted:false when the server silently downscopes (non-admin / unticked).
|
|
5427
|
+
|
|
5428
|
+
Runs on the thread pool (in _LONG_HANDLERS): the device flow blocks for the
|
|
5429
|
+
whole device-code lifetime (minutes), so it must not stall the main stdin loop.
|
|
5430
|
+
The verification URL/code reach the TUI via an out-of-band ``billing.step_up.
|
|
5431
|
+
verification`` event (a plain print would be dropped by the JSON-RPC stdout
|
|
5432
|
+
pipe), and the browser is opened TUI-side via openExternalUrl — never with the
|
|
5433
|
+
gateway's headless webbrowser.open (hence open_browser=False).
|
|
5434
|
+
"""
|
|
5435
|
+
sid = params.get("session_id") or ""
|
|
5436
|
+
try:
|
|
5437
|
+
from hermes_cli.auth import step_up_nous_billing_scope
|
|
5438
|
+
|
|
5439
|
+
def _on_verification(url: str, code: str) -> None:
|
|
5440
|
+
_emit(
|
|
5441
|
+
"billing.step_up.verification",
|
|
5442
|
+
sid,
|
|
5443
|
+
{"verification_url": url, "user_code": code},
|
|
5444
|
+
)
|
|
5445
|
+
|
|
5446
|
+
granted = step_up_nous_billing_scope(
|
|
5447
|
+
open_browser=False, on_verification=_on_verification
|
|
5448
|
+
)
|
|
5449
|
+
return _ok(rid, {"ok": True, "granted": bool(granted)})
|
|
5450
|
+
except Exception as exc:
|
|
5451
|
+
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False})
|
|
5452
|
+
|
|
3250
5453
|
|
|
3251
5454
|
@method("session.status")
|
|
3252
5455
|
def _(rid, params: dict) -> dict:
|
|
@@ -3457,54 +5660,67 @@ def _(rid, params: dict) -> dict:
|
|
|
3457
5660
|
session, err = _sess(params, rid)
|
|
3458
5661
|
if err:
|
|
3459
5662
|
return err
|
|
3460
|
-
import time as _time
|
|
3461
5663
|
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
)
|
|
5664
|
+
agent = session["agent"]
|
|
5665
|
+
# Mirror the classic CLI /save: snapshot under the Hermes profile home
|
|
5666
|
+
# (~/.hermes/sessions/saved/) rather than the project/workspace CWD, and
|
|
5667
|
+
# include the system prompt so the export matches the dashboard save.
|
|
5668
|
+
saved_dir = get_hermes_home() / "sessions" / "saved"
|
|
5669
|
+
try:
|
|
5670
|
+
saved_dir.mkdir(parents=True, exist_ok=True)
|
|
5671
|
+
except Exception as e:
|
|
5672
|
+
return _err(rid, 5011, f"failed to create save directory {saved_dir}: {e}")
|
|
5673
|
+
|
|
5674
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
5675
|
+
path = saved_dir / f"hermes_conversation_{timestamp}.json"
|
|
5676
|
+
|
|
5677
|
+
with session["history_lock"]:
|
|
5678
|
+
messages = list(session.get("history", []))
|
|
5679
|
+
|
|
5680
|
+
session_id = getattr(agent, "session_id", None) or session.get("session_key") or ""
|
|
5681
|
+
# Prefer the agent's session_start datetime (matches the classic CLI export);
|
|
5682
|
+
# fall back to the gateway session's created_at timestamp.
|
|
5683
|
+
agent_start = getattr(agent, "session_start", None)
|
|
5684
|
+
if isinstance(agent_start, datetime):
|
|
5685
|
+
session_start = agent_start.isoformat()
|
|
5686
|
+
else:
|
|
5687
|
+
created_at = session.get("created_at")
|
|
5688
|
+
session_start = (
|
|
5689
|
+
datetime.fromtimestamp(created_at).isoformat()
|
|
5690
|
+
if isinstance(created_at, (int, float))
|
|
5691
|
+
else ""
|
|
5692
|
+
)
|
|
5693
|
+
|
|
3465
5694
|
try:
|
|
3466
|
-
with open(
|
|
5695
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
3467
5696
|
json.dump(
|
|
3468
5697
|
{
|
|
3469
|
-
"model": getattr(
|
|
3470
|
-
"
|
|
5698
|
+
"model": getattr(agent, "model", ""),
|
|
5699
|
+
"session_id": session_id,
|
|
5700
|
+
"session_start": session_start,
|
|
5701
|
+
"system_prompt": getattr(agent, "_cached_system_prompt", "") or "",
|
|
5702
|
+
"messages": messages,
|
|
3471
5703
|
},
|
|
3472
5704
|
f,
|
|
3473
5705
|
indent=2,
|
|
3474
5706
|
ensure_ascii=False,
|
|
3475
5707
|
)
|
|
3476
|
-
return _ok(rid, {"file":
|
|
5708
|
+
return _ok(rid, {"file": str(path)})
|
|
3477
5709
|
except Exception as e:
|
|
3478
5710
|
return _err(rid, 5011, str(e))
|
|
3479
5711
|
|
|
3480
5712
|
|
|
3481
|
-
@method("session.close")
|
|
3482
|
-
def _(rid, params: dict) -> dict:
|
|
3483
|
-
sid = params.get("session_id", "")
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
except Exception:
|
|
3493
|
-
pass
|
|
3494
|
-
try:
|
|
3495
|
-
agent = session.get("agent")
|
|
3496
|
-
if agent and hasattr(agent, "close"):
|
|
3497
|
-
agent.close()
|
|
3498
|
-
except Exception:
|
|
3499
|
-
pass
|
|
3500
|
-
try:
|
|
3501
|
-
worker = session.get("slash_worker")
|
|
3502
|
-
if worker:
|
|
3503
|
-
worker.close()
|
|
3504
|
-
except Exception:
|
|
3505
|
-
pass
|
|
3506
|
-
return _ok(rid, {"closed": True})
|
|
3507
|
-
|
|
5713
|
+
@method("session.close")
|
|
5714
|
+
def _(rid, params: dict) -> dict:
|
|
5715
|
+
sid = params.get("session_id", "")
|
|
5716
|
+
# Serialize against the WS-orphan reaper (which also pops under
|
|
5717
|
+
# _session_resume_lock) so a disconnect-reap and an explicit close can't
|
|
5718
|
+
# both tear the same session down. _close_session_by_id is the single
|
|
5719
|
+
# idempotent teardown path (pop + _teardown_session) and returns False
|
|
5720
|
+
# when the session is already gone.
|
|
5721
|
+
with _session_resume_lock:
|
|
5722
|
+
return _ok(rid, {"closed": _close_session_by_id(sid, end_reason="tui_close")})
|
|
5723
|
+
|
|
3508
5724
|
|
|
3509
5725
|
@method("session.branch")
|
|
3510
5726
|
def _(rid, params: dict) -> dict:
|
|
@@ -3520,6 +5736,10 @@ def _(rid, params: dict) -> dict:
|
|
|
3520
5736
|
if not history:
|
|
3521
5737
|
return _err(rid, 4008, "nothing to branch — send a message first")
|
|
3522
5738
|
new_key = _new_session_key()
|
|
5739
|
+
new_sid = uuid.uuid4().hex[:8]
|
|
5740
|
+
lease, limit_message = _claim_active_session_slot(new_key, live_session_id=new_sid)
|
|
5741
|
+
if limit_message is not None:
|
|
5742
|
+
return _err(rid, 4090, limit_message)
|
|
3523
5743
|
branch_name = params.get("name", "")
|
|
3524
5744
|
try:
|
|
3525
5745
|
if branch_name:
|
|
@@ -3535,6 +5755,12 @@ def _(rid, params: dict) -> dict:
|
|
|
3535
5755
|
new_key,
|
|
3536
5756
|
source="tui",
|
|
3537
5757
|
model=_resolve_model(),
|
|
5758
|
+
# Stable _branched_from marker so list_sessions_rich() keeps the
|
|
5759
|
+
# branch visible in /resume and /sessions. The TUI branch leaves
|
|
5760
|
+
# the parent live (no end_reason='branched'), so the legacy
|
|
5761
|
+
# end_reason heuristic never matches it — the marker is the only
|
|
5762
|
+
# thing that surfaces TUI branches. See issue #20856.
|
|
5763
|
+
model_config={"_branched_from": old_key},
|
|
3538
5764
|
parent_session_id=old_key,
|
|
3539
5765
|
cwd=_session_cwd(session),
|
|
3540
5766
|
)
|
|
@@ -3546,8 +5772,9 @@ def _(rid, params: dict) -> dict:
|
|
|
3546
5772
|
)
|
|
3547
5773
|
db.set_session_title(new_key, title)
|
|
3548
5774
|
except Exception as e:
|
|
5775
|
+
if lease is not None:
|
|
5776
|
+
lease.release()
|
|
3549
5777
|
return _err(rid, 5008, f"branch failed: {e}")
|
|
3550
|
-
new_sid = uuid.uuid4().hex[:8]
|
|
3551
5778
|
try:
|
|
3552
5779
|
tokens = _set_session_context(new_key)
|
|
3553
5780
|
try:
|
|
@@ -3557,7 +5784,11 @@ def _(rid, params: dict) -> dict:
|
|
|
3557
5784
|
_init_session(
|
|
3558
5785
|
new_sid, new_key, agent, list(history), cols=session.get("cols", 80)
|
|
3559
5786
|
)
|
|
5787
|
+
if new_sid in _sessions:
|
|
5788
|
+
_sessions[new_sid]["active_session_lease"] = lease
|
|
3560
5789
|
except Exception as e:
|
|
5790
|
+
if lease is not None:
|
|
5791
|
+
lease.release()
|
|
3561
5792
|
return _err(rid, 5000, f"agent init failed on branch: {e}")
|
|
3562
5793
|
return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key})
|
|
3563
5794
|
|
|
@@ -3860,6 +6091,13 @@ def _(rid, params: dict) -> dict:
|
|
|
3860
6091
|
with session["history_lock"]:
|
|
3861
6092
|
if session.get("running"):
|
|
3862
6093
|
return _err(rid, 4009, "session busy")
|
|
6094
|
+
# A watch session's run lives in the PARENT turn, so its own running
|
|
6095
|
+
# flag is False — without this, typing mid-run builds a second agent
|
|
6096
|
+
# racing the in-flight child on the same stored session (interleaved
|
|
6097
|
+
# transcript, stale fork). After the run completes, submitting is fine:
|
|
6098
|
+
# the upgrade resumes the child's transcript as a normal conversation.
|
|
6099
|
+
if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")):
|
|
6100
|
+
return _err(rid, 4009, "subagent still running — wait for it to finish")
|
|
3863
6101
|
if truncate_user_ordinal is not None:
|
|
3864
6102
|
try:
|
|
3865
6103
|
ordinal = int(truncate_user_ordinal)
|
|
@@ -3924,7 +6162,8 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
|
|
|
3924
6162
|
if evt_key == str(session.get("session_key") or ""):
|
|
3925
6163
|
return False
|
|
3926
6164
|
try:
|
|
3927
|
-
|
|
6165
|
+
with _sessions_lock:
|
|
6166
|
+
snapshot = list(_sessions.values())
|
|
3928
6167
|
except Exception:
|
|
3929
6168
|
# If we can't safely enumerate live sessions, fail open so we don't
|
|
3930
6169
|
# crash the poller thread or drop the event.
|
|
@@ -3936,6 +6175,43 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
|
|
|
3936
6175
|
)
|
|
3937
6176
|
|
|
3938
6177
|
|
|
6178
|
+
def _notification_event_dedup_key(evt: dict) -> tuple:
|
|
6179
|
+
"""Return the UI-emission identity for a process notification event.
|
|
6180
|
+
|
|
6181
|
+
Completion events are terminal notifications for a background process, so
|
|
6182
|
+
they remain one-shot per process session. Watch-match events are not
|
|
6183
|
+
terminal: a single background process can legitimately match the same or
|
|
6184
|
+
different patterns many times, so include event-specific content to avoid
|
|
6185
|
+
suppressing later distinct matches from the same process.
|
|
6186
|
+
"""
|
|
6187
|
+
evt_type = evt.get("type", "completion")
|
|
6188
|
+
evt_sid = evt.get("session_id", "")
|
|
6189
|
+
if evt_type == "watch_match":
|
|
6190
|
+
return (
|
|
6191
|
+
evt_sid,
|
|
6192
|
+
evt_type,
|
|
6193
|
+
evt.get("command", ""),
|
|
6194
|
+
evt.get("pattern", ""),
|
|
6195
|
+
evt.get("output", ""),
|
|
6196
|
+
evt.get("suppressed", 0),
|
|
6197
|
+
evt.get("message_id", ""),
|
|
6198
|
+
)
|
|
6199
|
+
if evt_type.startswith("watch_overflow_") or evt_type == "watch_disabled":
|
|
6200
|
+
return (
|
|
6201
|
+
evt_sid,
|
|
6202
|
+
evt_type,
|
|
6203
|
+
evt.get("command", ""),
|
|
6204
|
+
evt.get("message", ""),
|
|
6205
|
+
evt.get("suppressed", 0),
|
|
6206
|
+
)
|
|
6207
|
+
if evt_type == "async_delegation":
|
|
6208
|
+
# Async-delegation completions have no process session_id; without
|
|
6209
|
+
# this the fallthrough keys every one as ("", "async_delegation")
|
|
6210
|
+
# and the second completion's status update is suppressed forever.
|
|
6211
|
+
return (evt.get("delegation_id", ""), evt_type)
|
|
6212
|
+
return (evt_sid, evt_type)
|
|
6213
|
+
|
|
6214
|
+
|
|
3939
6215
|
def _notification_poller_loop(
|
|
3940
6216
|
stop_event: threading.Event, sid: str, session: dict
|
|
3941
6217
|
) -> None:
|
|
@@ -3952,6 +6228,7 @@ def _notification_poller_loop(
|
|
|
3952
6228
|
"""
|
|
3953
6229
|
from tools.process_registry import process_registry, format_process_notification
|
|
3954
6230
|
|
|
6231
|
+
_emitted = set() # dedup re-queued events so same completion isn't emitted 50 times while session is busy
|
|
3955
6232
|
while not stop_event.is_set() and not session.get("_finalized"):
|
|
3956
6233
|
try:
|
|
3957
6234
|
evt = process_registry.completion_queue.get(timeout=0.5)
|
|
@@ -3976,7 +6253,14 @@ def _notification_poller_loop(
|
|
|
3976
6253
|
if not text:
|
|
3977
6254
|
continue
|
|
3978
6255
|
|
|
3979
|
-
|
|
6256
|
+
# Only emit the same notification identity to TUI once — re-queued
|
|
6257
|
+
# completions get re-emitted every 0.5s otherwise when session is busy,
|
|
6258
|
+
# while distinct watch_match events from the same process must remain
|
|
6259
|
+
# visible independently.
|
|
6260
|
+
_dedup_key = _notification_event_dedup_key(evt)
|
|
6261
|
+
if _dedup_key not in _emitted:
|
|
6262
|
+
_emit("status.update", sid, {"kind": "process", "text": text})
|
|
6263
|
+
_emitted.add(_dedup_key)
|
|
3980
6264
|
|
|
3981
6265
|
with session["history_lock"]:
|
|
3982
6266
|
if session.get("running"):
|
|
@@ -4016,7 +6300,10 @@ def _notification_poller_loop(
|
|
|
4016
6300
|
if not text:
|
|
4017
6301
|
continue
|
|
4018
6302
|
|
|
4019
|
-
|
|
6303
|
+
_dedup_key = _notification_event_dedup_key(evt)
|
|
6304
|
+
if _dedup_key not in _emitted:
|
|
6305
|
+
_emit("status.update", sid, {"kind": "process", "text": text})
|
|
6306
|
+
_emitted.add(_dedup_key)
|
|
4020
6307
|
|
|
4021
6308
|
with session["history_lock"]:
|
|
4022
6309
|
if session.get("running"):
|
|
@@ -4068,6 +6355,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|
|
4068
6355
|
def run():
|
|
4069
6356
|
approval_token = None
|
|
4070
6357
|
session_tokens = []
|
|
6358
|
+
home_token = None # per-turn HERMES_HOME override for a resumed remote profile
|
|
4071
6359
|
goal_followup = None # set by the post-turn goal hook below
|
|
4072
6360
|
try:
|
|
4073
6361
|
from tools.approval import (
|
|
@@ -4077,6 +6365,17 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|
|
4077
6365
|
|
|
4078
6366
|
approval_token = set_current_session_key(session["session_key"])
|
|
4079
6367
|
session_tokens = _set_session_context(session["session_key"])
|
|
6368
|
+
_profile_home_str = session.get("profile_home")
|
|
6369
|
+
if _profile_home_str:
|
|
6370
|
+
home_token = set_hermes_home_override(_profile_home_str)
|
|
6371
|
+
# The sudo password callback is thread-local (tools.terminal_tool
|
|
6372
|
+
# _callback_tls), so wiring it on the build thread doesn't reach this
|
|
6373
|
+
# turn thread — terminal sudo prompts would fall through to /dev/tty
|
|
6374
|
+
# and hang the headless gateway. Re-wire here so the prompt routes to
|
|
6375
|
+
# the sudo.request overlay. (secret capture is a module global, so
|
|
6376
|
+
# re-running is a harmless no-op.)
|
|
6377
|
+
_wire_callbacks(sid)
|
|
6378
|
+
_sync_agent_model_with_config(sid, session)
|
|
4080
6379
|
cwd = _session_cwd(session)
|
|
4081
6380
|
_register_session_cwd(session)
|
|
4082
6381
|
cols = session.get("cols", 80)
|
|
@@ -4398,6 +6697,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|
|
4398
6697
|
reset_current_session_key(approval_token)
|
|
4399
6698
|
except Exception:
|
|
4400
6699
|
pass
|
|
6700
|
+
if home_token is not None:
|
|
6701
|
+
reset_hermes_home_override(home_token)
|
|
4401
6702
|
_clear_session_context(session_tokens)
|
|
4402
6703
|
with session["history_lock"]:
|
|
4403
6704
|
session["running"] = False
|
|
@@ -4546,6 +6847,465 @@ def _(rid, params: dict) -> dict:
|
|
|
4546
6847
|
return _err(rid, 5027, str(e))
|
|
4547
6848
|
|
|
4548
6849
|
|
|
6850
|
+
# Byte-upload attach caps. 25 MB matches Anthropic's per-image limit; 50 MB / 25
|
|
6851
|
+
# pages bounds a single PDF drop so it can't blow the context budget.
|
|
6852
|
+
_ATTACH_BYTES_MAX_BYTES = 25 * 1024 * 1024
|
|
6853
|
+
_PDF_ATTACH_MAX_BYTES = 50 * 1024 * 1024
|
|
6854
|
+
_PDF_ATTACH_MAX_PAGES = 25
|
|
6855
|
+
|
|
6856
|
+
# Leading magic bytes → file extension, for filename-less uploads.
|
|
6857
|
+
_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = (
|
|
6858
|
+
(b"\x89PNG\r\n\x1a\n", ".png"),
|
|
6859
|
+
(b"\xff\xd8\xff", ".jpg"),
|
|
6860
|
+
(b"GIF87a", ".gif"),
|
|
6861
|
+
(b"GIF89a", ".gif"),
|
|
6862
|
+
(b"BM", ".bmp"),
|
|
6863
|
+
)
|
|
6864
|
+
|
|
6865
|
+
|
|
6866
|
+
def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
|
|
6867
|
+
"""Decode a base64 (optionally data-URL-wrapped) payload.
|
|
6868
|
+
|
|
6869
|
+
Accepts ``data:<mime_prefix>...;base64,<b64>`` plus embedded whitespace.
|
|
6870
|
+
Returns the decoded bytes, or ``None`` when the input isn't valid base64.
|
|
6871
|
+
"""
|
|
6872
|
+
import base64 as _base64
|
|
6873
|
+
import re as _re
|
|
6874
|
+
|
|
6875
|
+
cleaned = raw.strip()
|
|
6876
|
+
m = _re.match(
|
|
6877
|
+
rf"^data:{_re.escape(mime_prefix)}[a-zA-Z0-9.+-]*;base64,(.*)$",
|
|
6878
|
+
cleaned,
|
|
6879
|
+
_re.DOTALL,
|
|
6880
|
+
)
|
|
6881
|
+
if m:
|
|
6882
|
+
cleaned = m.group(1)
|
|
6883
|
+
cleaned = _re.sub(r"\s+", "", cleaned)
|
|
6884
|
+
try:
|
|
6885
|
+
return _base64.b64decode(cleaned, validate=True)
|
|
6886
|
+
except Exception:
|
|
6887
|
+
return None
|
|
6888
|
+
|
|
6889
|
+
|
|
6890
|
+
def _sniff_image_ext(img_bytes: bytes, filename: str = "") -> str:
|
|
6891
|
+
"""Resolve an image extension from a filename hint, else magic bytes.
|
|
6892
|
+
|
|
6893
|
+
Falls back to ``.png``. WebP needs the RIFF/WEBP container check, handled
|
|
6894
|
+
before the generic table.
|
|
6895
|
+
"""
|
|
6896
|
+
if filename:
|
|
6897
|
+
suffix = Path(filename).suffix.lower()
|
|
6898
|
+
if suffix:
|
|
6899
|
+
return suffix
|
|
6900
|
+
head = img_bytes[:16]
|
|
6901
|
+
if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
|
|
6902
|
+
return ".webp"
|
|
6903
|
+
for sig, ext in _IMAGE_MAGIC:
|
|
6904
|
+
if head.startswith(sig):
|
|
6905
|
+
return ext
|
|
6906
|
+
return ".png"
|
|
6907
|
+
|
|
6908
|
+
|
|
6909
|
+
def _allowed_image_extensions() -> frozenset[str]:
|
|
6910
|
+
try:
|
|
6911
|
+
from cli import _IMAGE_EXTENSIONS
|
|
6912
|
+
|
|
6913
|
+
return frozenset(_IMAGE_EXTENSIONS)
|
|
6914
|
+
except Exception:
|
|
6915
|
+
return frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
|
6916
|
+
|
|
6917
|
+
|
|
6918
|
+
def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix: str) -> Path:
|
|
6919
|
+
"""Write image bytes into the gateway's images dir and queue them.
|
|
6920
|
+
|
|
6921
|
+
Mirrors what ``image.attach`` does for a local path: appends to
|
|
6922
|
+
``session["attached_images"]`` so the next ``prompt.submit`` picks it up via
|
|
6923
|
+
the existing native-image-attach pipeline. Returns the written path.
|
|
6924
|
+
"""
|
|
6925
|
+
session["image_counter"] = session.get("image_counter", 0) + 1
|
|
6926
|
+
img_dir = _hermes_home / "images"
|
|
6927
|
+
img_dir.mkdir(parents=True, exist_ok=True)
|
|
6928
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
6929
|
+
img_path = img_dir / f"{prefix}_{ts}_{session['image_counter']}{ext}"
|
|
6930
|
+
try:
|
|
6931
|
+
img_path.write_bytes(img_bytes)
|
|
6932
|
+
except Exception:
|
|
6933
|
+
session["image_counter"] = max(0, session["image_counter"] - 1)
|
|
6934
|
+
raise
|
|
6935
|
+
session.setdefault("attached_images", []).append(str(img_path))
|
|
6936
|
+
return img_path
|
|
6937
|
+
|
|
6938
|
+
|
|
6939
|
+
@method("image.attach_bytes")
|
|
6940
|
+
def _(rid, params: dict) -> dict:
|
|
6941
|
+
"""Attach an image to the session from base64 bytes (remote-client path).
|
|
6942
|
+
|
|
6943
|
+
A desktop app or web dashboard running on a DIFFERENT machine than the
|
|
6944
|
+
gateway can't hand us a local path — that file only exists on the client's
|
|
6945
|
+
disk. So it uploads the raw image bytes (base64) and we write them into the
|
|
6946
|
+
gateway's own images dir. The response shape mirrors ``image.attach`` so the
|
|
6947
|
+
client treats both identically.
|
|
6948
|
+
|
|
6949
|
+
Params:
|
|
6950
|
+
content_base64 / data (str, required): base64 image bytes. Accepts a
|
|
6951
|
+
``data:image/...;base64,`` prefix and embedded whitespace. ``data`` is
|
|
6952
|
+
an accepted alias for older desktop builds.
|
|
6953
|
+
filename / ext (str, optional): extension hint. Without it, magic bytes
|
|
6954
|
+
identify PNG/JPEG/GIF/WebP/BMP, falling back to ``.png``.
|
|
6955
|
+
"""
|
|
6956
|
+
session, err = _sess(params, rid)
|
|
6957
|
+
if err:
|
|
6958
|
+
return err
|
|
6959
|
+
|
|
6960
|
+
raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
|
|
6961
|
+
if not raw_b64:
|
|
6962
|
+
return _err(rid, 4015, "content_base64 required")
|
|
6963
|
+
|
|
6964
|
+
img_bytes = _decode_attach_base64(raw_b64, mime_prefix="image/")
|
|
6965
|
+
if img_bytes is None:
|
|
6966
|
+
return _err(rid, 4017, "data is not valid base64")
|
|
6967
|
+
if not img_bytes:
|
|
6968
|
+
return _err(rid, 4017, "image is empty")
|
|
6969
|
+
if len(img_bytes) > _ATTACH_BYTES_MAX_BYTES:
|
|
6970
|
+
mb = _ATTACH_BYTES_MAX_BYTES // (1024 * 1024)
|
|
6971
|
+
return _err(rid, 4018, f"image too large ({len(img_bytes)} bytes; cap is {mb} MB)")
|
|
6972
|
+
|
|
6973
|
+
filename = str(params.get("filename", "") or "")
|
|
6974
|
+
ext_hint = str(params.get("ext", "") or "").strip().lower()
|
|
6975
|
+
if ext_hint and not ext_hint.startswith("."):
|
|
6976
|
+
ext_hint = "." + ext_hint
|
|
6977
|
+
ext = _sniff_image_ext(img_bytes, filename or (f"x{ext_hint}" if ext_hint else ""))
|
|
6978
|
+
if ext not in _allowed_image_extensions():
|
|
6979
|
+
return _err(rid, 4016, f"unsupported image extension: {ext}")
|
|
6980
|
+
|
|
6981
|
+
try:
|
|
6982
|
+
img_path = _queue_attached_image(session, img_bytes, ext, prefix="upload")
|
|
6983
|
+
except Exception as e:
|
|
6984
|
+
return _err(rid, 5027, f"write failed: {e}")
|
|
6985
|
+
|
|
6986
|
+
return _ok(
|
|
6987
|
+
rid,
|
|
6988
|
+
{
|
|
6989
|
+
"attached": True,
|
|
6990
|
+
"path": str(img_path),
|
|
6991
|
+
"count": len(session["attached_images"]),
|
|
6992
|
+
"remainder": "",
|
|
6993
|
+
"text": f"[User attached image: {img_path.name}]",
|
|
6994
|
+
"bytes": len(img_bytes),
|
|
6995
|
+
**_image_meta(img_path),
|
|
6996
|
+
},
|
|
6997
|
+
)
|
|
6998
|
+
|
|
6999
|
+
|
|
7000
|
+
@method("pdf.attach")
|
|
7001
|
+
def _(rid, params: dict) -> dict:
|
|
7002
|
+
"""Attach a PDF by rendering each page to PNG and queuing the pages.
|
|
7003
|
+
|
|
7004
|
+
Anthropic's vision pipeline accepts images, not PDFs, so this runs
|
|
7005
|
+
``pdftoppm`` (poppler-utils) at 150 DPI per page and queues each rendered
|
|
7006
|
+
page as an attached image. Accepts either a host ``path`` (local mode) or
|
|
7007
|
+
base64 ``content_base64`` (remote upload). Caps at 50 MB / 25 pages per call.
|
|
7008
|
+
|
|
7009
|
+
Requires ``pdftoppm`` on $PATH (``apt install poppler-utils``); returns 5028
|
|
7010
|
+
if missing.
|
|
7011
|
+
"""
|
|
7012
|
+
import shutil
|
|
7013
|
+
import subprocess
|
|
7014
|
+
import tempfile
|
|
7015
|
+
|
|
7016
|
+
session, err = _sess(params, rid)
|
|
7017
|
+
if err:
|
|
7018
|
+
return err
|
|
7019
|
+
|
|
7020
|
+
if shutil.which("pdftoppm") is None:
|
|
7021
|
+
return _err(rid, 5028, "pdftoppm not installed (poppler-utils package required)")
|
|
7022
|
+
|
|
7023
|
+
raw_path = str(params.get("path", "") or "").strip()
|
|
7024
|
+
raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
|
|
7025
|
+
if not raw_path and not raw_b64:
|
|
7026
|
+
return _err(rid, 4015, "path or content_base64 required")
|
|
7027
|
+
|
|
7028
|
+
with tempfile.TemporaryDirectory(prefix="pdf_attach_") as td:
|
|
7029
|
+
td_path = Path(td)
|
|
7030
|
+
if raw_b64:
|
|
7031
|
+
pdf_bytes = _decode_attach_base64(raw_b64, mime_prefix="application/pdf")
|
|
7032
|
+
if pdf_bytes is None:
|
|
7033
|
+
return _err(rid, 4017, "data is not valid base64")
|
|
7034
|
+
if not pdf_bytes:
|
|
7035
|
+
return _err(rid, 4017, "decoded PDF is empty")
|
|
7036
|
+
if len(pdf_bytes) > _PDF_ATTACH_MAX_BYTES:
|
|
7037
|
+
mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
|
|
7038
|
+
return _err(rid, 4018, f"PDF too large ({len(pdf_bytes)} bytes; cap is {mb} MB)")
|
|
7039
|
+
if pdf_bytes[:5] != b"%PDF-":
|
|
7040
|
+
return _err(rid, 4017, "payload is not a PDF (missing %PDF- magic bytes)")
|
|
7041
|
+
pdf_path = td_path / "input.pdf"
|
|
7042
|
+
pdf_path.write_bytes(pdf_bytes)
|
|
7043
|
+
display_name = str(params.get("filename", "") or "uploaded.pdf")
|
|
7044
|
+
else:
|
|
7045
|
+
try:
|
|
7046
|
+
from cli import _resolve_attachment_path
|
|
7047
|
+
|
|
7048
|
+
resolved = _resolve_attachment_path(raw_path)
|
|
7049
|
+
except Exception:
|
|
7050
|
+
resolved = None
|
|
7051
|
+
if resolved is None or not Path(resolved).is_file():
|
|
7052
|
+
return _err(rid, 4016, f"PDF not found: {raw_path}")
|
|
7053
|
+
if Path(resolved).suffix.lower() != ".pdf":
|
|
7054
|
+
return _err(rid, 4016, f"not a PDF: {Path(resolved).name}")
|
|
7055
|
+
if Path(resolved).stat().st_size > _PDF_ATTACH_MAX_BYTES:
|
|
7056
|
+
mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
|
|
7057
|
+
return _err(rid, 4018, f"PDF too large; cap is {mb} MB")
|
|
7058
|
+
pdf_path = Path(resolved)
|
|
7059
|
+
display_name = pdf_path.name
|
|
7060
|
+
|
|
7061
|
+
try:
|
|
7062
|
+
first_page = int(params.get("first_page") or 1)
|
|
7063
|
+
last_page_param = params.get("last_page")
|
|
7064
|
+
last_page = int(last_page_param) if last_page_param is not None else None
|
|
7065
|
+
except (TypeError, ValueError):
|
|
7066
|
+
return _err(rid, 4015, "first_page/last_page must be integers")
|
|
7067
|
+
|
|
7068
|
+
if first_page < 1:
|
|
7069
|
+
return _err(rid, 4015, "first_page must be >= 1")
|
|
7070
|
+
if last_page is None:
|
|
7071
|
+
last_page = first_page + _PDF_ATTACH_MAX_PAGES - 1
|
|
7072
|
+
if last_page < first_page:
|
|
7073
|
+
return _err(rid, 4015, "last_page must be >= first_page")
|
|
7074
|
+
if last_page - first_page + 1 > _PDF_ATTACH_MAX_PAGES:
|
|
7075
|
+
return _err(rid, 4019, f"page range exceeds cap of {_PDF_ATTACH_MAX_PAGES} pages per attach call")
|
|
7076
|
+
|
|
7077
|
+
out_prefix = td_path / "page"
|
|
7078
|
+
argv = [
|
|
7079
|
+
"pdftoppm", "-png", "-r", "150",
|
|
7080
|
+
"-f", str(first_page), "-l", str(last_page),
|
|
7081
|
+
str(pdf_path), str(out_prefix),
|
|
7082
|
+
]
|
|
7083
|
+
try:
|
|
7084
|
+
res = subprocess.run(argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
|
7085
|
+
except subprocess.TimeoutExpired:
|
|
7086
|
+
return _err(rid, 5028, "pdftoppm timed out (>120s)")
|
|
7087
|
+
if res.returncode != 0:
|
|
7088
|
+
tail = (res.stderr or res.stdout or "").strip().splitlines()[-3:]
|
|
7089
|
+
return _err(rid, 5028, "pdftoppm failed: " + " | ".join(tail))
|
|
7090
|
+
|
|
7091
|
+
rendered = sorted(td_path.glob("page-*.png"))
|
|
7092
|
+
if not rendered:
|
|
7093
|
+
return _err(rid, 5028, "pdftoppm produced no pages (corrupt PDF?)")
|
|
7094
|
+
|
|
7095
|
+
attached_pages = []
|
|
7096
|
+
for src in rendered:
|
|
7097
|
+
page_num = src.stem.split("-", 1)[-1]
|
|
7098
|
+
try:
|
|
7099
|
+
page_int = int(page_num)
|
|
7100
|
+
except ValueError:
|
|
7101
|
+
page_int = first_page + len(attached_pages)
|
|
7102
|
+
dst = _queue_attached_image(session, src.read_bytes(), ".png", prefix=f"pdf_p{page_num}")
|
|
7103
|
+
attached_pages.append({"path": str(dst), "page": page_int, **_image_meta(dst)})
|
|
7104
|
+
|
|
7105
|
+
return _ok(
|
|
7106
|
+
rid,
|
|
7107
|
+
{
|
|
7108
|
+
"attached": True,
|
|
7109
|
+
"filename": display_name,
|
|
7110
|
+
"pages_attached": len(attached_pages),
|
|
7111
|
+
"pages": attached_pages,
|
|
7112
|
+
"count": len(session["attached_images"]),
|
|
7113
|
+
"text": f"[User attached PDF: {display_name} ({len(attached_pages)} page(s))]",
|
|
7114
|
+
},
|
|
7115
|
+
)
|
|
7116
|
+
|
|
7117
|
+
|
|
7118
|
+
_ATTACHMENT_REF_NEEDS_QUOTING_RE = None
|
|
7119
|
+
|
|
7120
|
+
|
|
7121
|
+
def _format_ref_value(value: str) -> str:
|
|
7122
|
+
"""Quote a context-ref value when it contains whitespace or bracket chars.
|
|
7123
|
+
|
|
7124
|
+
Mirrors the desktop ``formatRefValue`` so the staged ``@file:`` ref round-trips
|
|
7125
|
+
through ``agent.context_references`` cleanly.
|
|
7126
|
+
"""
|
|
7127
|
+
import re as _re
|
|
7128
|
+
|
|
7129
|
+
global _ATTACHMENT_REF_NEEDS_QUOTING_RE
|
|
7130
|
+
if _ATTACHMENT_REF_NEEDS_QUOTING_RE is None:
|
|
7131
|
+
_ATTACHMENT_REF_NEEDS_QUOTING_RE = _re.compile(r"""[\s()\[\]{}<>"'`]""")
|
|
7132
|
+
if not value or not _ATTACHMENT_REF_NEEDS_QUOTING_RE.search(value):
|
|
7133
|
+
return value
|
|
7134
|
+
if "`" not in value:
|
|
7135
|
+
return f"`{value}`"
|
|
7136
|
+
if '"' not in value:
|
|
7137
|
+
return f'"{value}"'
|
|
7138
|
+
if "'" not in value:
|
|
7139
|
+
return f"'{value}'"
|
|
7140
|
+
return value
|
|
7141
|
+
|
|
7142
|
+
|
|
7143
|
+
def _attachment_ref_path(session: dict, target: Path) -> str:
|
|
7144
|
+
"""Workspace-relative path for an attachment, or the absolute path if outside."""
|
|
7145
|
+
workspace = Path(_session_cwd(session)).resolve()
|
|
7146
|
+
try:
|
|
7147
|
+
rel = target.resolve().relative_to(workspace)
|
|
7148
|
+
return str(rel).replace(os.sep, "/")
|
|
7149
|
+
except ValueError:
|
|
7150
|
+
return str(target.resolve())
|
|
7151
|
+
|
|
7152
|
+
|
|
7153
|
+
def _desktop_attachment_dir(session: dict) -> Path:
|
|
7154
|
+
root = Path(_session_cwd(session)).resolve() / ".hermes" / "desktop-attachments"
|
|
7155
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
7156
|
+
return root
|
|
7157
|
+
|
|
7158
|
+
|
|
7159
|
+
def _sanitize_attachment_name(name: str) -> str:
|
|
7160
|
+
import re as _re
|
|
7161
|
+
|
|
7162
|
+
candidate = Path(str(name or "").strip()).name
|
|
7163
|
+
candidate = _re.sub(r"[\x00-\x1f]+", "_", candidate)
|
|
7164
|
+
candidate = candidate.strip().strip(".")
|
|
7165
|
+
return candidate or "attachment"
|
|
7166
|
+
|
|
7167
|
+
|
|
7168
|
+
def _unique_attachment_path(root: Path, filename: str) -> Path:
|
|
7169
|
+
candidate = root / filename
|
|
7170
|
+
if not candidate.exists():
|
|
7171
|
+
return candidate
|
|
7172
|
+
stem = Path(filename).stem or "attachment"
|
|
7173
|
+
suffix = Path(filename).suffix
|
|
7174
|
+
counter = 2
|
|
7175
|
+
while True:
|
|
7176
|
+
next_candidate = root / f"{stem}-{counter}{suffix}"
|
|
7177
|
+
if not next_candidate.exists():
|
|
7178
|
+
return next_candidate
|
|
7179
|
+
counter += 1
|
|
7180
|
+
|
|
7181
|
+
|
|
7182
|
+
def _resolve_gateway_attachment_path(raw: str) -> Path | None:
|
|
7183
|
+
"""Resolve a raw path token to a gateway-visible file, or None."""
|
|
7184
|
+
if not raw:
|
|
7185
|
+
return None
|
|
7186
|
+
try:
|
|
7187
|
+
from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
|
|
7188
|
+
except Exception:
|
|
7189
|
+
return None
|
|
7190
|
+
|
|
7191
|
+
dropped = _detect_file_drop(raw)
|
|
7192
|
+
if dropped:
|
|
7193
|
+
return Path(dropped["path"]).resolve()
|
|
7194
|
+
path_token, _remainder = _split_path_input(raw)
|
|
7195
|
+
resolved = _resolve_attachment_path(path_token)
|
|
7196
|
+
return Path(resolved).resolve() if resolved is not None else None
|
|
7197
|
+
|
|
7198
|
+
|
|
7199
|
+
def _decode_attachment_data_url(data_url: str) -> bytes:
|
|
7200
|
+
"""Decode a ``data:<any-mime>;base64,<b64>`` payload to bytes.
|
|
7201
|
+
|
|
7202
|
+
Unlike ``_decode_attach_base64`` (image-mime-specific), this accepts any
|
|
7203
|
+
media type — text/csv, application/pdf, etc. — so non-image file uploads
|
|
7204
|
+
round-trip. Also tolerates a bare base64 string with no data-URL prefix.
|
|
7205
|
+
"""
|
|
7206
|
+
import base64 as _base64
|
|
7207
|
+
import binascii as _binascii
|
|
7208
|
+
import re as _re
|
|
7209
|
+
|
|
7210
|
+
cleaned = (data_url or "").strip()
|
|
7211
|
+
m = _re.match(r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", cleaned, _re.DOTALL | _re.I)
|
|
7212
|
+
if m:
|
|
7213
|
+
cleaned = m.group(1)
|
|
7214
|
+
cleaned = _re.sub(r"\s+", "", cleaned)
|
|
7215
|
+
try:
|
|
7216
|
+
return _base64.b64decode(cleaned, validate=True)
|
|
7217
|
+
except (ValueError, _binascii.Error) as exc:
|
|
7218
|
+
raise ValueError("invalid data_url payload") from exc
|
|
7219
|
+
|
|
7220
|
+
|
|
7221
|
+
def _stage_session_file_attachment(
|
|
7222
|
+
session: dict,
|
|
7223
|
+
*,
|
|
7224
|
+
raw_path: str,
|
|
7225
|
+
data_url: str,
|
|
7226
|
+
name: str,
|
|
7227
|
+
) -> tuple[Path, bool]:
|
|
7228
|
+
"""Make a desktop file attachment available to the remote gateway agent.
|
|
7229
|
+
|
|
7230
|
+
Three cases:
|
|
7231
|
+
1. The path resolves to a file already INSIDE the session workspace — use
|
|
7232
|
+
it as-is (no copy, ``uploaded=False``).
|
|
7233
|
+
2. The path resolves to a gateway-visible file OUTSIDE the workspace — copy
|
|
7234
|
+
it into ``.hermes/desktop-attachments/`` so the ``@file:`` ref resolves.
|
|
7235
|
+
3. The path doesn't exist on the gateway (the common remote case: it's a
|
|
7236
|
+
path on the CLIENT's disk) — decode the uploaded ``data_url`` bytes and
|
|
7237
|
+
write them into ``.hermes/desktop-attachments/``.
|
|
7238
|
+
|
|
7239
|
+
Returns ``(stored_path, uploaded)``.
|
|
7240
|
+
"""
|
|
7241
|
+
workspace = Path(_session_cwd(session)).resolve()
|
|
7242
|
+
resolved = _resolve_gateway_attachment_path(raw_path)
|
|
7243
|
+
if resolved is not None:
|
|
7244
|
+
try:
|
|
7245
|
+
resolved.relative_to(workspace)
|
|
7246
|
+
return resolved, False
|
|
7247
|
+
except ValueError:
|
|
7248
|
+
payload = resolved.read_bytes()
|
|
7249
|
+
filename = resolved.name
|
|
7250
|
+
else:
|
|
7251
|
+
if not data_url:
|
|
7252
|
+
raise ValueError("file not found on gateway and no data_url provided")
|
|
7253
|
+
payload = _decode_attachment_data_url(data_url)
|
|
7254
|
+
filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
|
|
7255
|
+
|
|
7256
|
+
upload_dir = _desktop_attachment_dir(session)
|
|
7257
|
+
target = _unique_attachment_path(upload_dir, _sanitize_attachment_name(filename))
|
|
7258
|
+
target.write_bytes(payload)
|
|
7259
|
+
return target.resolve(), True
|
|
7260
|
+
|
|
7261
|
+
|
|
7262
|
+
@method("file.attach")
|
|
7263
|
+
def _(rid, params: dict) -> dict:
|
|
7264
|
+
"""Stage a non-image file attachment into the session workspace.
|
|
7265
|
+
|
|
7266
|
+
The image/PDF path renders to vision tiles; this one keeps the file as a
|
|
7267
|
+
readable artifact and returns a workspace-relative ``@file:`` ref so the
|
|
7268
|
+
agent's file tools (and ``agent.context_references``) can read it. Solves the
|
|
7269
|
+
remote-gateway case where the desktop passes a path that only exists on the
|
|
7270
|
+
CLIENT's disk: the client uploads ``data_url`` bytes and we materialize the
|
|
7271
|
+
file on the gateway.
|
|
7272
|
+
|
|
7273
|
+
Params:
|
|
7274
|
+
session_id (str, required)
|
|
7275
|
+
path (str): client/host path of the file (used for naming + local-mode
|
|
7276
|
+
gateway-visible resolution).
|
|
7277
|
+
data_url (str): ``data:<mime>;base64,<b64>`` upload of the file bytes,
|
|
7278
|
+
required when the path isn't visible to the gateway.
|
|
7279
|
+
name (str, optional): preferred filename.
|
|
7280
|
+
"""
|
|
7281
|
+
session, err = _sess(params, rid)
|
|
7282
|
+
if err:
|
|
7283
|
+
return err
|
|
7284
|
+
raw = str(params.get("path", "") or "").strip()
|
|
7285
|
+
data_url = str(params.get("data_url", "") or "").strip()
|
|
7286
|
+
name = str(params.get("name", "") or "").strip()
|
|
7287
|
+
if not raw and not data_url:
|
|
7288
|
+
return _err(rid, 4015, "path or data_url required")
|
|
7289
|
+
try:
|
|
7290
|
+
stored_path, uploaded = _stage_session_file_attachment(
|
|
7291
|
+
session, raw_path=raw, data_url=data_url, name=name
|
|
7292
|
+
)
|
|
7293
|
+
ref_path = _attachment_ref_path(session, stored_path)
|
|
7294
|
+
return _ok(
|
|
7295
|
+
rid,
|
|
7296
|
+
{
|
|
7297
|
+
"attached": True,
|
|
7298
|
+
"name": stored_path.name,
|
|
7299
|
+
"path": str(stored_path),
|
|
7300
|
+
"ref_path": ref_path,
|
|
7301
|
+
"ref_text": f"@file:{_format_ref_value(ref_path)}",
|
|
7302
|
+
"uploaded": uploaded,
|
|
7303
|
+
},
|
|
7304
|
+
)
|
|
7305
|
+
except Exception as e:
|
|
7306
|
+
return _err(rid, 5028, str(e))
|
|
7307
|
+
|
|
7308
|
+
|
|
4549
7309
|
@method("image.detach")
|
|
4550
7310
|
def _(rid, params: dict) -> dict:
|
|
4551
7311
|
session, err = _sess(params, rid)
|
|
@@ -4624,7 +7384,7 @@ def _(rid, params: dict) -> dict:
|
|
|
4624
7384
|
task_id = f"bg_{uuid.uuid4().hex[:6]}"
|
|
4625
7385
|
|
|
4626
7386
|
def run():
|
|
4627
|
-
session_tokens = _set_session_context(task_id)
|
|
7387
|
+
session_tokens = _set_session_context(task_id, cwd=_session_cwd(session))
|
|
4628
7388
|
try:
|
|
4629
7389
|
from run_agent import AIAgent
|
|
4630
7390
|
|
|
@@ -4709,14 +7469,25 @@ def _(rid, params: dict) -> dict:
|
|
|
4709
7469
|
if line
|
|
4710
7470
|
)
|
|
4711
7471
|
|
|
7472
|
+
# Normalize defensively: a malformed client path (embedded NUL, etc.) must
|
|
7473
|
+
# not blow up the whole restart — treat it as "no validated cwd".
|
|
7474
|
+
try:
|
|
7475
|
+
preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else ""
|
|
7476
|
+
if preview_cwd and not os.path.isdir(preview_cwd):
|
|
7477
|
+
preview_cwd = ""
|
|
7478
|
+
except Exception:
|
|
7479
|
+
preview_cwd = ""
|
|
7480
|
+
|
|
4712
7481
|
def run():
|
|
4713
|
-
|
|
7482
|
+
# Pin the validated preview cwd, else the parent workspace — never an
|
|
7483
|
+
# invalid client path, which would silently fall back to the launch dir.
|
|
7484
|
+
session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session)))
|
|
4714
7485
|
try:
|
|
4715
7486
|
from run_agent import AIAgent
|
|
4716
7487
|
from tools.terminal_tool import register_task_env_overrides
|
|
4717
7488
|
|
|
4718
|
-
if
|
|
4719
|
-
register_task_env_overrides(task_id, {"cwd":
|
|
7489
|
+
if preview_cwd:
|
|
7490
|
+
register_task_env_overrides(task_id, {"cwd": preview_cwd})
|
|
4720
7491
|
|
|
4721
7492
|
history_note = (
|
|
4722
7493
|
f" (with {len(parent_history)} parent-session messages of context)"
|
|
@@ -4766,12 +7537,13 @@ def _(rid, params: dict) -> dict:
|
|
|
4766
7537
|
|
|
4767
7538
|
def _respond(rid, params, key):
|
|
4768
7539
|
r = params.get("request_id", "")
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
7540
|
+
with _prompt_lock:
|
|
7541
|
+
entry = _pending.get(r)
|
|
7542
|
+
if not entry:
|
|
7543
|
+
return _err(rid, 4009, f"no pending {key} request")
|
|
7544
|
+
_, ev = entry
|
|
7545
|
+
_answers[r] = params.get(key, "")
|
|
7546
|
+
ev.set()
|
|
4775
7547
|
return _ok(rid, {"status": "ok"})
|
|
4776
7548
|
|
|
4777
7549
|
|
|
@@ -4780,6 +7552,12 @@ def _(rid, params: dict) -> dict:
|
|
|
4780
7552
|
return _respond(rid, params, "answer")
|
|
4781
7553
|
|
|
4782
7554
|
|
|
7555
|
+
@method("terminal.read.respond")
|
|
7556
|
+
def _(rid, params: dict) -> dict:
|
|
7557
|
+
# `text` is a JSON string of the serialized terminal buffer + line metadata.
|
|
7558
|
+
return _respond(rid, params, "text")
|
|
7559
|
+
|
|
7560
|
+
|
|
4783
7561
|
@method("sudo.respond")
|
|
4784
7562
|
def _(rid, params: dict) -> dict:
|
|
4785
7563
|
return _respond(rid, params, "password")
|
|
@@ -4839,7 +7617,11 @@ def _(rid, params: dict) -> dict:
|
|
|
4839
7617
|
4009,
|
|
4840
7618
|
"session busy — /interrupt the current turn before switching models",
|
|
4841
7619
|
)
|
|
4842
|
-
|
|
7620
|
+
from hermes_cli.model_switch import parse_model_flags
|
|
7621
|
+
|
|
7622
|
+
parsed_flags = parse_model_flags(value)
|
|
7623
|
+
_model_input, explicit_provider, _persist_global, _force_refresh, _is_session = parsed_flags
|
|
7624
|
+
if session.get("agent") is None and not explicit_provider.strip():
|
|
4843
7625
|
session_id = params.get("session_id", "")
|
|
4844
7626
|
_start_agent_build(session_id, session)
|
|
4845
7627
|
init_err = _wait_agent(session, rid)
|
|
@@ -4848,13 +7630,32 @@ def _(rid, params: dict) -> dict:
|
|
|
4848
7630
|
if session.get("agent") is None:
|
|
4849
7631
|
return _err(rid, 5032, "agent initialization failed")
|
|
4850
7632
|
result = _apply_model_switch(
|
|
4851
|
-
params.get("session_id", ""),
|
|
7633
|
+
params.get("session_id", ""),
|
|
7634
|
+
session,
|
|
7635
|
+
value,
|
|
7636
|
+
confirm_expensive_model=bool(
|
|
7637
|
+
params.get("confirm_expensive_model", False)
|
|
7638
|
+
),
|
|
7639
|
+
parsed_flags=parsed_flags,
|
|
4852
7640
|
)
|
|
4853
7641
|
else:
|
|
4854
|
-
result = _apply_model_switch(
|
|
7642
|
+
result = _apply_model_switch(
|
|
7643
|
+
"",
|
|
7644
|
+
{"agent": None},
|
|
7645
|
+
value,
|
|
7646
|
+
confirm_expensive_model=bool(
|
|
7647
|
+
params.get("confirm_expensive_model", False)
|
|
7648
|
+
),
|
|
7649
|
+
)
|
|
4855
7650
|
return _ok(
|
|
4856
7651
|
rid,
|
|
4857
|
-
{
|
|
7652
|
+
{
|
|
7653
|
+
"key": key,
|
|
7654
|
+
"value": result["value"],
|
|
7655
|
+
"warning": result["warning"],
|
|
7656
|
+
"confirm_required": result.get("confirm_required", False),
|
|
7657
|
+
"confirm_message": result.get("confirm_message", ""),
|
|
7658
|
+
},
|
|
4858
7659
|
)
|
|
4859
7660
|
except Exception as e:
|
|
4860
7661
|
return _err(rid, 5001, str(e))
|
|
@@ -4912,6 +7713,7 @@ def _(rid, params: dict) -> dict:
|
|
|
4912
7713
|
if nv == "fast":
|
|
4913
7714
|
current_overrides.update(overrides)
|
|
4914
7715
|
agent.request_overrides = current_overrides
|
|
7716
|
+
_persist_live_session_runtime(session)
|
|
4915
7717
|
_emit(
|
|
4916
7718
|
"session.info",
|
|
4917
7719
|
params.get("session_id", ""),
|
|
@@ -4954,30 +7756,79 @@ def _(rid, params: dict) -> dict:
|
|
|
4954
7756
|
return _ok(rid, {"key": key, "value": nv})
|
|
4955
7757
|
|
|
4956
7758
|
if key == "yolo":
|
|
7759
|
+
# Approval bypass. Two scopes:
|
|
7760
|
+
# scope="session" (default) — same as the TUI's Shift+Tab. Toggles
|
|
7761
|
+
# ONLY this session's _session_yolo flag; never touches global
|
|
7762
|
+
# config, so CLI / TUI / cron behavior is unaffected.
|
|
7763
|
+
# scope="global" (Shift+click the zap) — flips the persistent global
|
|
7764
|
+
# approvals.mode in config.yaml between "off" (bypass on) and
|
|
7765
|
+
# "manual" (bypass off). This DOES affect every session, the CLI,
|
|
7766
|
+
# the TUI, and cron, and survives restarts.
|
|
7767
|
+
scope = str(params.get("scope") or "session").strip().lower()
|
|
4957
7768
|
try:
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
7769
|
+
from tools.approval import (
|
|
7770
|
+
disable_session_yolo,
|
|
7771
|
+
enable_session_yolo,
|
|
7772
|
+
is_session_yolo_enabled,
|
|
7773
|
+
)
|
|
7774
|
+
|
|
7775
|
+
raw = str(value or "").strip().lower()
|
|
7776
|
+
|
|
7777
|
+
def _resolve_toggle(current: bool) -> bool:
|
|
7778
|
+
if raw in {"1", "on", "true", "yes"}:
|
|
7779
|
+
return True
|
|
7780
|
+
if raw in {"0", "off", "false", "no"}:
|
|
7781
|
+
return False
|
|
7782
|
+
return not current
|
|
7783
|
+
|
|
7784
|
+
if scope == "global":
|
|
7785
|
+
from tools.approval import _normalize_approval_mode
|
|
4964
7786
|
|
|
7787
|
+
cfg = _load_cfg()
|
|
7788
|
+
appr = cfg.get("approvals") if isinstance(cfg, dict) else None
|
|
7789
|
+
if not isinstance(appr, dict):
|
|
7790
|
+
appr = {}
|
|
7791
|
+
current = _normalize_approval_mode(appr.get("mode", "manual")) == "off"
|
|
7792
|
+
enable = _resolve_toggle(current)
|
|
7793
|
+
# Toggle between full bypass and the default manual gate. We do
|
|
7794
|
+
# not try to restore a prior "smart"/custom mode — the zap is a
|
|
7795
|
+
# binary on/off affordance; users with bespoke modes set them in
|
|
7796
|
+
# config.yaml.
|
|
7797
|
+
_write_config_key("approvals.mode", "off" if enable else "manual")
|
|
7798
|
+
nv = "1" if enable else "0"
|
|
7799
|
+
# Reflect the global flip in every live session's indicator.
|
|
7800
|
+
for sid, sess in list(_sessions.items()):
|
|
7801
|
+
agent = sess.get("agent")
|
|
7802
|
+
if agent is not None:
|
|
7803
|
+
_emit("session.info", sid, _session_info(agent, sess))
|
|
7804
|
+
return _ok(rid, {"key": key, "value": nv, "scope": "global"})
|
|
7805
|
+
|
|
7806
|
+
if session:
|
|
4965
7807
|
current = is_session_yolo_enabled(session["session_key"])
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
nv = "0"
|
|
4969
|
-
else:
|
|
7808
|
+
enable = _resolve_toggle(current)
|
|
7809
|
+
if enable:
|
|
4970
7810
|
enable_session_yolo(session["session_key"])
|
|
4971
7811
|
nv = "1"
|
|
7812
|
+
else:
|
|
7813
|
+
disable_session_yolo(session["session_key"])
|
|
7814
|
+
nv = "0"
|
|
7815
|
+
agent = session.get("agent")
|
|
7816
|
+
if agent is not None:
|
|
7817
|
+
_emit(
|
|
7818
|
+
"session.info",
|
|
7819
|
+
params.get("session_id", ""),
|
|
7820
|
+
_session_info(agent, session),
|
|
7821
|
+
)
|
|
4972
7822
|
else:
|
|
4973
7823
|
current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
nv = "0"
|
|
4977
|
-
else:
|
|
7824
|
+
enable = _resolve_toggle(current)
|
|
7825
|
+
if enable:
|
|
4978
7826
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
4979
7827
|
nv = "1"
|
|
4980
|
-
|
|
7828
|
+
else:
|
|
7829
|
+
os.environ.pop("HERMES_YOLO_MODE", None)
|
|
7830
|
+
nv = "0"
|
|
7831
|
+
return _ok(rid, {"key": key, "value": nv, "scope": "session"})
|
|
4981
7832
|
except Exception as e:
|
|
4982
7833
|
return _err(rid, 5001, str(e))
|
|
4983
7834
|
|
|
@@ -5029,6 +7880,12 @@ def _(rid, params: dict) -> dict:
|
|
|
5029
7880
|
_write_config_key("agent.reasoning_effort", arg)
|
|
5030
7881
|
if session and session.get("agent") is not None:
|
|
5031
7882
|
session["agent"].reasoning_config = parsed
|
|
7883
|
+
_persist_live_session_runtime(session)
|
|
7884
|
+
_emit(
|
|
7885
|
+
"session.info",
|
|
7886
|
+
params.get("session_id", ""),
|
|
7887
|
+
_session_info(session["agent"], session),
|
|
7888
|
+
)
|
|
5032
7889
|
return _ok(rid, {"key": key, "value": arg})
|
|
5033
7890
|
except Exception as e:
|
|
5034
7891
|
return _err(rid, 5001, str(e))
|
|
@@ -5443,6 +8300,58 @@ def _(rid, params: dict) -> dict:
|
|
|
5443
8300
|
return _err(rid, 5010, str(e))
|
|
5444
8301
|
|
|
5445
8302
|
|
|
8303
|
+
def _session_processes(session: dict) -> list:
|
|
8304
|
+
"""Background processes owned by this session (registry session_key match)."""
|
|
8305
|
+
from tools.process_registry import process_registry
|
|
8306
|
+
|
|
8307
|
+
key = str(session.get("session_key") or "")
|
|
8308
|
+
owned = []
|
|
8309
|
+
for entry in process_registry.list_sessions():
|
|
8310
|
+
proc = process_registry.get(entry["session_id"])
|
|
8311
|
+
if proc is None or str(getattr(proc, "session_key", "") or "") != key:
|
|
8312
|
+
continue
|
|
8313
|
+
# The 200-char list preview is too thin for the desktop's inline
|
|
8314
|
+
# terminal viewer — ship a real tail alongside it.
|
|
8315
|
+
entry["output_tail"] = (proc.output_buffer or "")[-4000:]
|
|
8316
|
+
owned.append(entry)
|
|
8317
|
+
return owned
|
|
8318
|
+
|
|
8319
|
+
|
|
8320
|
+
@method("process.list")
|
|
8321
|
+
def _(rid, params: dict) -> dict:
|
|
8322
|
+
"""Session-scoped view of the background process registry (desktop status stack)."""
|
|
8323
|
+
session, err = _sess(params, rid)
|
|
8324
|
+
if err:
|
|
8325
|
+
return err
|
|
8326
|
+
try:
|
|
8327
|
+
return _ok(rid, {"processes": _session_processes(session)})
|
|
8328
|
+
except Exception as e:
|
|
8329
|
+
return _err(rid, 5010, str(e))
|
|
8330
|
+
|
|
8331
|
+
|
|
8332
|
+
@method("process.kill")
|
|
8333
|
+
def _(rid, params: dict) -> dict:
|
|
8334
|
+
"""Kill ONE background process — scoped to the caller's session so one
|
|
8335
|
+
window can't reap another session's work (unlike process.stop's kill_all)."""
|
|
8336
|
+
session, err = _sess(params, rid)
|
|
8337
|
+
if err:
|
|
8338
|
+
return err
|
|
8339
|
+
proc_id = str(params.get("process_id") or "")
|
|
8340
|
+
if not proc_id:
|
|
8341
|
+
return _err(rid, 4012, "process_id required")
|
|
8342
|
+
try:
|
|
8343
|
+
from tools.process_registry import process_registry
|
|
8344
|
+
|
|
8345
|
+
proc = process_registry.get(proc_id)
|
|
8346
|
+
if proc is None or str(getattr(proc, "session_key", "") or "") != str(
|
|
8347
|
+
session.get("session_key") or ""
|
|
8348
|
+
):
|
|
8349
|
+
return _err(rid, 4044, f"no such process: {proc_id}")
|
|
8350
|
+
return _ok(rid, process_registry.kill_process(proc_id))
|
|
8351
|
+
except Exception as e:
|
|
8352
|
+
return _err(rid, 5010, str(e))
|
|
8353
|
+
|
|
8354
|
+
|
|
5446
8355
|
@method("reload.mcp")
|
|
5447
8356
|
def _(rid, params: dict) -> dict:
|
|
5448
8357
|
session = _sessions.get(params.get("session_id", ""))
|
|
@@ -5498,16 +8407,15 @@ def _(rid, params: dict) -> dict:
|
|
|
5498
8407
|
# The user already consented to the prompt-cache invalidation via
|
|
5499
8408
|
# the confirm gate above. Mirrors gateway/run.py::_execute_mcp_reload.
|
|
5500
8409
|
try:
|
|
5501
|
-
from
|
|
8410
|
+
from tools.mcp_tool import refresh_agent_mcp_tools
|
|
5502
8411
|
|
|
5503
|
-
|
|
5504
|
-
|
|
8412
|
+
# Explicit reload: re-resolve enabled toolsets so a server the
|
|
8413
|
+
# user just enabled in config this session is picked up.
|
|
8414
|
+
refresh_agent_mcp_tools(
|
|
8415
|
+
agent,
|
|
8416
|
+
enabled_override=_load_enabled_toolsets(),
|
|
5505
8417
|
quiet_mode=True,
|
|
5506
8418
|
)
|
|
5507
|
-
agent.tools = new_defs
|
|
5508
|
-
agent.valid_tool_names = (
|
|
5509
|
-
{t["function"]["name"] for t in new_defs} if new_defs else set()
|
|
5510
|
-
)
|
|
5511
8419
|
except Exception as _exc:
|
|
5512
8420
|
logger.warning(
|
|
5513
8421
|
"Failed to refresh cached agent tools after /reload-mcp: %s",
|
|
@@ -5577,7 +8485,9 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [
|
|
|
5577
8485
|
|
|
5578
8486
|
# Commands that queue messages onto _pending_input in the CLI.
|
|
5579
8487
|
# In the TUI the slash worker subprocess has no reader for that queue,
|
|
5580
|
-
# so slash.exec
|
|
8488
|
+
# so slash.exec routes them to command.dispatch internally (which handles
|
|
8489
|
+
# them and returns a structured payload) instead of erroring out and
|
|
8490
|
+
# relying on a client-side fallback. See #48848.
|
|
5581
8491
|
_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
|
|
5582
8492
|
{
|
|
5583
8493
|
"retry",
|
|
@@ -5725,6 +8635,7 @@ def _(rid, params: dict) -> dict:
|
|
|
5725
8635
|
timeout=min(int(params.get("timeout", 240)), 600),
|
|
5726
8636
|
cwd=os.getcwd(),
|
|
5727
8637
|
env=os.environ.copy(),
|
|
8638
|
+
stdin=subprocess.DEVNULL,
|
|
5728
8639
|
)
|
|
5729
8640
|
parts = [r.stdout or "", r.stderr or ""]
|
|
5730
8641
|
out = "\n".join(p for p in parts if p).strip() or "(no output)"
|
|
@@ -5785,6 +8696,7 @@ def _(rid, params: dict) -> dict:
|
|
|
5785
8696
|
capture_output=True,
|
|
5786
8697
|
text=True,
|
|
5787
8698
|
timeout=30,
|
|
8699
|
+
stdin=subprocess.DEVNULL,
|
|
5788
8700
|
)
|
|
5789
8701
|
output = (
|
|
5790
8702
|
(r.stdout or "")
|
|
@@ -6175,6 +9087,7 @@ def _list_repo_files(root: str) -> list[str]:
|
|
|
6175
9087
|
capture_output=True,
|
|
6176
9088
|
timeout=2.0,
|
|
6177
9089
|
check=False,
|
|
9090
|
+
stdin=subprocess.DEVNULL,
|
|
6178
9091
|
)
|
|
6179
9092
|
if top_result.returncode == 0:
|
|
6180
9093
|
top = top_result.stdout.decode("utf-8", "replace").strip()
|
|
@@ -6192,6 +9105,7 @@ def _list_repo_files(root: str) -> list[str]:
|
|
|
6192
9105
|
capture_output=True,
|
|
6193
9106
|
timeout=2.0,
|
|
6194
9107
|
check=False,
|
|
9108
|
+
stdin=subprocess.DEVNULL,
|
|
6195
9109
|
)
|
|
6196
9110
|
if list_result.returncode == 0:
|
|
6197
9111
|
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
|
|
@@ -6627,7 +9541,8 @@ def _(rid, params: dict) -> dict:
|
|
|
6627
9541
|
picker_hints=True,
|
|
6628
9542
|
canonical_order=True,
|
|
6629
9543
|
pricing=True,
|
|
6630
|
-
|
|
9544
|
+
capabilities=True,
|
|
9545
|
+
refresh=bool(params.get("refresh")),
|
|
6631
9546
|
)
|
|
6632
9547
|
return _ok(rid, payload)
|
|
6633
9548
|
except Exception as e:
|
|
@@ -6839,8 +9754,16 @@ def _(rid, params: dict) -> dict:
|
|
|
6839
9754
|
_cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else ""
|
|
6840
9755
|
|
|
6841
9756
|
if _cmd_base in _PENDING_INPUT_COMMANDS:
|
|
6842
|
-
|
|
6843
|
-
|
|
9757
|
+
# Route directly to command.dispatch instead of returning an error
|
|
9758
|
+
# that requires the frontend to retry. Some TUI clients fail the
|
|
9759
|
+
# fallback, leaving the command empty and showing "empty command".
|
|
9760
|
+
return _methods["command.dispatch"](
|
|
9761
|
+
rid,
|
|
9762
|
+
{
|
|
9763
|
+
"name": _cmd_base,
|
|
9764
|
+
"arg": _cmd_arg,
|
|
9765
|
+
"session_id": params.get("session_id", ""),
|
|
9766
|
+
},
|
|
6844
9767
|
)
|
|
6845
9768
|
|
|
6846
9769
|
if _cmd_base in _WORKER_BLOCKED_COMMANDS:
|
|
@@ -6891,7 +9814,7 @@ def _(rid, params: dict) -> dict:
|
|
|
6891
9814
|
session["session_key"],
|
|
6892
9815
|
getattr(session.get("agent"), "model", _resolve_model()),
|
|
6893
9816
|
)
|
|
6894
|
-
|
|
9817
|
+
_attach_worker(params.get("session_id", ""), session, worker)
|
|
6895
9818
|
except Exception as e:
|
|
6896
9819
|
return _err(rid, 5030, f"slash worker start failed: {e}")
|
|
6897
9820
|
|
|
@@ -7899,7 +10822,83 @@ def _(rid, params: dict) -> dict:
|
|
|
7899
10822
|
return _err(rid, 5025, str(e))
|
|
7900
10823
|
|
|
7901
10824
|
|
|
7902
|
-
|
|
10825
|
+
@method("plugins.manage")
|
|
10826
|
+
def _(rid, params: dict) -> dict:
|
|
10827
|
+
"""List installed plugins with activation state, or toggle one on/off.
|
|
10828
|
+
|
|
10829
|
+
Backs the TUI Plugins Hub. Uses the same disk-discovery + enable/disable
|
|
10830
|
+
primitives as ``hermes plugins`` / the dashboard, so the three surfaces
|
|
10831
|
+
agree on what's installed and what's enabled.
|
|
10832
|
+
|
|
10833
|
+
Actions:
|
|
10834
|
+
- ``list`` → {"plugins": [{name, version, description, source,
|
|
10835
|
+
status}], "user_count": N, "bundled_count": M}
|
|
10836
|
+
- ``toggle`` → flip ``name`` based on ``enable`` (bool). Returns the
|
|
10837
|
+
refreshed row plus {"ok", "unchanged"}.
|
|
10838
|
+
"""
|
|
10839
|
+
action = params.get("action", "list")
|
|
10840
|
+
try:
|
|
10841
|
+
from hermes_cli.plugins_cmd import (
|
|
10842
|
+
_discover_all_plugins,
|
|
10843
|
+
_get_disabled_set,
|
|
10844
|
+
_get_enabled_set,
|
|
10845
|
+
_plugin_status,
|
|
10846
|
+
)
|
|
10847
|
+
|
|
10848
|
+
def _rows():
|
|
10849
|
+
enabled = _get_enabled_set()
|
|
10850
|
+
disabled = _get_disabled_set()
|
|
10851
|
+
out = []
|
|
10852
|
+
for name, version, desc, source, _dir, key in sorted(
|
|
10853
|
+
_discover_all_plugins()
|
|
10854
|
+
):
|
|
10855
|
+
out.append(
|
|
10856
|
+
{
|
|
10857
|
+
"name": name,
|
|
10858
|
+
"version": str(version or ""),
|
|
10859
|
+
"description": desc or "",
|
|
10860
|
+
"source": source,
|
|
10861
|
+
"status": _plugin_status(name, enabled, disabled, key=key),
|
|
10862
|
+
}
|
|
10863
|
+
)
|
|
10864
|
+
return out
|
|
10865
|
+
|
|
10866
|
+
if action == "list":
|
|
10867
|
+
rows = _rows()
|
|
10868
|
+
user_count = sum(1 for r in rows if r["source"] != "bundled")
|
|
10869
|
+
return _ok(
|
|
10870
|
+
rid,
|
|
10871
|
+
{
|
|
10872
|
+
"plugins": rows,
|
|
10873
|
+
"user_count": user_count,
|
|
10874
|
+
"bundled_count": len(rows) - user_count,
|
|
10875
|
+
},
|
|
10876
|
+
)
|
|
10877
|
+
|
|
10878
|
+
if action == "toggle":
|
|
10879
|
+
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
|
|
10880
|
+
|
|
10881
|
+
name = (params.get("name") or "").strip()
|
|
10882
|
+
if not name:
|
|
10883
|
+
return _err(rid, 4019, "plugins.toggle requires a 'name'")
|
|
10884
|
+
enable = bool(params.get("enable"))
|
|
10885
|
+
result = dashboard_set_agent_plugin_enabled(name, enabled=enable)
|
|
10886
|
+
if not result.get("ok"):
|
|
10887
|
+
return _err(rid, 5026, result.get("error") or "toggle failed")
|
|
10888
|
+
row = next((r for r in _rows() if r["name"] == name), None)
|
|
10889
|
+
return _ok(
|
|
10890
|
+
rid,
|
|
10891
|
+
{
|
|
10892
|
+
"ok": True,
|
|
10893
|
+
"unchanged": bool(result.get("unchanged")),
|
|
10894
|
+
"name": name,
|
|
10895
|
+
"plugin": row,
|
|
10896
|
+
},
|
|
10897
|
+
)
|
|
10898
|
+
|
|
10899
|
+
return _err(rid, 4017, f"unknown plugins action: {action}")
|
|
10900
|
+
except Exception as e:
|
|
10901
|
+
return _err(rid, 5026, str(e))
|
|
7903
10902
|
|
|
7904
10903
|
|
|
7905
10904
|
@method("shell.exec")
|
|
@@ -7908,18 +10907,24 @@ def _(rid, params: dict) -> dict:
|
|
|
7908
10907
|
if not cmd:
|
|
7909
10908
|
return _err(rid, 4004, "empty command")
|
|
7910
10909
|
try:
|
|
7911
|
-
from tools.approval import detect_dangerous_command
|
|
10910
|
+
from tools.approval import detect_dangerous_command, detect_hardline_command
|
|
7912
10911
|
|
|
10912
|
+
is_hardline, hardline_desc = detect_hardline_command(cmd)
|
|
10913
|
+
if is_hardline:
|
|
10914
|
+
return _err(
|
|
10915
|
+
rid, 4005, f"blocked (hardline): {hardline_desc}. Use the agent for dangerous commands."
|
|
10916
|
+
)
|
|
7913
10917
|
is_dangerous, _, desc = detect_dangerous_command(cmd)
|
|
7914
10918
|
if is_dangerous:
|
|
7915
10919
|
return _err(
|
|
7916
10920
|
rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands."
|
|
7917
10921
|
)
|
|
7918
10922
|
except ImportError:
|
|
7919
|
-
|
|
10923
|
+
return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable")
|
|
7920
10924
|
try:
|
|
7921
10925
|
r = subprocess.run(
|
|
7922
|
-
cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()
|
|
10926
|
+
cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd(),
|
|
10927
|
+
stdin=subprocess.DEVNULL,
|
|
7923
10928
|
)
|
|
7924
10929
|
return _ok(
|
|
7925
10930
|
rid,
|