diff --git a/.github/workflows/mac-dmg.yml b/.github/workflows/mac-dmg.yml new file mode 100644 index 000000000..f512f185a --- /dev/null +++ b/.github/workflows/mac-dmg.yml @@ -0,0 +1,99 @@ +name: Mac DMG (unsigned, on-demand) + +# One-shot, manually-triggered build of an UNSIGNED macOS .dmg on GitHub's +# hosted macOS runners. Unlike release.yml's release_mac job, this needs NO +# Apple secrets (no notarization, no code-signing) and publishes nothing — it +# just uploads the .dmg as a workflow artifact for download. +# +# Gatekeeper note: because the result is unsigned + un-notarized, on first launch +# macOS will block it. The user opens it once via right-click -> Open, or runs: +# xattr -dr com.apple.quarantine "/Applications/Hermes One.app" + +on: + workflow_dispatch: + inputs: + arch: + description: "Target architecture" + type: choice + options: + - both + - arm64 + - x64 + default: both + +jobs: + build: + name: Build macOS DMG (${{ matrix.arch }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + # Build both by default; the if-guard skips the arch the user didn't pick. + arch: [arm64, x64] + steps: + - name: Skip unselected arch + id: gate + run: | + want='${{ github.event.inputs.arch }}' + if [ "$want" = "both" ] || [ "$want" = "${{ matrix.arch }}" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "Skipping ${{ matrix.arch }} (user selected $want)" + fi + + - name: Check out repository + if: steps.gate.outputs.run == 'true' + uses: actions/checkout@v4 + + - name: Set up Node.js + if: steps.gate.outputs.run == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + if: steps.gate.outputs.run == 'true' + run: npm ci + + - name: Rebuild native dependencies for target arch + if: steps.gate.outputs.run == 'true' + run: npx electron-builder install-app-deps --arch=${{ matrix.arch }} + + - name: Build app + if: steps.gate.outputs.run == 'true' + run: npm run build + + - name: Package UNSIGNED macOS DMG + if: steps.gate.outputs.run == 'true' + env: + # Force-disable code discovery so electron-builder does not try to sign. + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: >- + npx electron-builder --mac dmg --${{ matrix.arch }} + --publish never + -c.mac.notarize=false + -c.mac.identity=null + + - name: Verify native module architecture + if: steps.gate.outputs.run == 'true' + run: | + set -euo pipefail + NODE_FILE="dist/mac-${{ matrix.arch }}/Hermes One.app/Contents/Resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/better_sqlite3.node" + if [ ! -f "$NODE_FILE" ]; then + NODE_FILE="dist/mac/Hermes One.app/Contents/Resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/better_sqlite3.node" + fi + file "$NODE_FILE" + case "${{ matrix.arch }}" in + x64) file "$NODE_FILE" | grep -q "x86_64" ;; + arm64) file "$NODE_FILE" | grep -q "arm64" ;; + esac + + - name: Upload DMG artifact + if: steps.gate.outputs.run == 'true' + uses: actions/upload-artifact@v4 + with: + name: hermes-mac-${{ matrix.arch }}-dmg + path: dist/*.dmg + if-no-files-found: error diff --git a/dev-app-update.yml b/dev-app-update.yml index 70d7e4d23..8517c5450 100644 --- a/dev-app-update.yml +++ b/dev-app-update.yml @@ -1,4 +1,4 @@ provider: github -owner: fathah -repo: hermes-desktop +owner: BAS-More +repo: hermes-desktop-Working- updaterCacheDirName: hermes-desktop-updater diff --git a/docs/orchestrator-loop-PRD.md b/docs/orchestrator-loop-PRD.md new file mode 100644 index 000000000..dbbc4e944 --- /dev/null +++ b/docs/orchestrator-loop-PRD.md @@ -0,0 +1,175 @@ +# Orchestrator Closed-Loop — PRD + +Status: DRAFT FOR APPROVAL +Engine: BAS-More/hermes-agent fork · Desktop: feat/factory-tab +Author: Avi + Claude (Opus 4.8), 2026-06-14 + +## The vision (Avi's words) + +> The orchestrator is given the results he must achieve and the guidelines (code +> quality, security, etc.). He picks the team of agents/sub-agents for the task, +> oversees what they're doing, and keeps them running in the react loop to the +> successful finish. + +## What exists vs. the gap + +| Duty | Status | Note | +|---|---|---| +| Given guidelines (quality/security) | ✅ done | the governor + secret scanner + avi-os-gates/tdad skills | +| Picks the team | ✅ done | the decomposer routes each child to the best profile | +| Given results to achieve | ◑ partial | a card body is the goal; no structured acceptance criteria | +| **Oversees in-flight** | ❌ gap | fans out ONCE, then sleeps until all children finish | +| **React loop to finish** | ❌ gap | when children finish the root just auto-promotes; nobody verifies the ASSEMBLED result vs the goal and spawns corrective work | + +Today = **fan-out-once-then-assemble**. Target = **plan → verify → re-plan → loop-until-done**. The difference is the closed loop. + +## Architecture decision (grounded) + +Avi chose "engine dispatcher, autonomous, headless." The honest synthesis: the +**dispatcher (Python) owns the loop control-flow** (detect state, spawn, enforce +bounds), and a **spawned orchestrator worker (LLM) does the judgment** — the +dispatcher cannot itself judge "does this meet the goal?". This reuses the proven +review-worker machinery (`status='review'`, `claim_review_task`, the review +dispatch block at kanban_db.py:6439) — the orchestrator-verify is essentially a +review worker for the build ROOT that can re-open the build. + +## The closed loop (new behavior) + +``` +triage card (goal + guidelines) + │ decompose (EXISTING) — orchestrator picks team, seeds .ezra governance + ▼ +children run (EXISTING) — per-task goal-mode workers, governed + │ all children done + ▼ +ROOT → 'review' (NEW: instead of auto-promote to ready/done) + │ dispatcher spawns the ORCHESTRATOR profile as a build-verify worker + ▼ +orchestrator-verify worker (NEW skill: orchestrator-verify) + reads: the build goal + acceptance criteria + each child's result/artifacts + judges: does the ASSEMBLED result meet the goal + guidelines? + ├─ PASS → complete the root (build done) ✅ + └─ FAIL → record the gap + create N corrective child tasks under the root + → root back to 'todo' (waits on the new children) + ▼ +corrective children run → root → 'review' again → re-verify (THE LOOP) + bounded by: build iteration ceiling + per-block retry cap + a NEW + max_verify_rounds; on exhaustion → park root 'blocked' for human. +``` + +## Engine work + +### E1. Acceptance criteria as a first-class build record +- At decompose, the orchestrator records the build's **acceptance criteria** + ("done when…") into the build's `.ezra/` (new `acceptance.yaml` or a field in + governance.yaml) + the root task metadata. Source: extracted from the card + body by the decomposer LLM (it already produces structured JSON — add an + `acceptance` field to its output schema). +- Read by the verify worker; surfaced in `govern --json`. + +### E2. Root → review instead of auto-promote +- `recompute_ready` / the child-completion path: when ALL children of a root are + done AND the root is a build-root (has an acceptance record / a new + `is_build_root` flag), transition the root to `review` + assign the + orchestrator profile, instead of promoting to `ready`/auto-done. +- Gate this on a config flag `kanban.orchestrator_loop: true` (default OFF first + — this changes core dispatch; opt-in until proven) so existing builds are + unaffected. + +### E3. orchestrator-verify skill + worker +- A new skill `orchestrator-verify` (HOME skills/, like sdlc-review): instructs + the worker to read the goal + acceptance + child results, judge PASS/FAIL with + reasons, and on FAIL emit a structured list of corrective child tasks. +- The review dispatch already force-loads `sdlc-review` for review workers; add + branch: if the review task is a BUILD ROOT, load `orchestrator-verify` instead. + +### E4. Re-decompose on FAIL (the corrective spawn) +- A helper `reopen_build_with_children(root_id, children, reason)`: creates the + corrective child tasks under the existing root, links them, transitions the + root `review → todo` (waits on new children), records the verify verdict + + reason as a root event. Reuses `decompose_triage_task`'s child-insert logic + (factor out the shared insert). + +### E5. Loop bounds (the runaway guards — non-negotiable) +- `max_verify_rounds` (default 3): root metadata counter; each FAIL→reopen + increments. On exhaustion the root parks `blocked` ("verify loop exhausted — + human review") rather than looping forever. +- The EXISTING budget breaker (wallclock + iteration ceiling) still bounds the + whole subtree — the corrective children count as iterations. +- The EXISTING per-block retry cap still applies per worker. +- So three independent ceilings bound the loop; it cannot run away (critical + given the ~93% weekly quota reality). + +### E6. govern --json surfacing +- Build status: per-build goal, acceptance criteria, current verify round, + last verdict + reason, loop state (running / verifying / corrective / done / + parked). Feeds the Factory tab. + +## Desktop work (after engine proven) +- Factory tab: a "Builds" view or section showing each active build — goal, + acceptance criteria, which agents are on it, verify round N/max, last verdict, + live state. This is the "oversees what they're doing" pane. +- (Optional) a build detail with the corrective-task history (the loop made + visible). + +## Verification plan +- Unit: acceptance record write/read; root→review transition gated by flag; + reopen_build_with_children; max_verify_rounds parks at the cap. +- Live proof: a real build whose first attempt is deliberately incomplete → + orchestrator-verify FAILS → spawns a corrective task → it runs → re-verify + PASSES → root done. Then a build that can never pass → confirm it parks at + max_verify_rounds (loop doesn't run away). +- Regression: with `orchestrator_loop: false` (default), existing fan-out + behavior is byte-identical (no regression for current builds). + +## Safety + rollout +- `orchestrator_loop` defaults OFF. Turn ON for one board / one build to prove, + then default-on once trusted. +- Fork-durable (engine), restore-guard markers, all the usual. +- This touches CORE dispatch (root completion) — the flag-gate + the + byte-identical-when-off regression are mandatory before it ships on. + +## LOCKED DECISIONS (Avi, 2026-06-14) +1. **Acceptance criteria: AUTO-extract from the card** (decomposer LLM derives the + "done when…"; editable in UI later). +2. **max_verify_rounds = 3.** +3. **On FAIL the orchestrator COMMANDS DIRECTIVE adjustments** — corrective tasks + are specific ("builder did X wrong; do Y to meet criterion Z"), getting more + pointed each round, guiding the builder to success. NOT vague re-decompose. +4. **On exhaustion (3 rounds): ESCALATE to human with full diagnosis + the + specific recommended fix, and park.** Not silent give-up, not ship-best-effort. + The orchestrator guides autonomously within the 3-round envelope; the ceiling + exists only because unbounded looping risks the ~93% weekly quota. +5. **Rollout: flag OFF by default** (`kanban.orchestrator_loop`), prove on one + build, then consider default-on. Byte-identical-when-off regression mandatory. + +Design consequence of #3: E3 (orchestrator-verify) must output, on FAIL, a +structured PER-CRITERION gap analysis + directive correction per gap — the +corrective task bodies are those directives. E4 carries the orchestrator's +reason/diagnosis into each corrective task so the builder gets specific guidance, +not a re-statement of the original goal. + +## FOLDED FROM "Loop Engineering" research (Avi, 2026-06-14) + +The datasciencedojo loop-engineering guide names guardrails we were missing. +Both folded into the build: + +6. **No-progress detection (REQUIRED guardrail).** The verify worker records a + fingerprint of each round's assembled result. If round N's fingerprint equals + round N-1's, the corrective work produced NO change ("silent failure" / + "insistent failure" — the article's hardest-to-catch mode). Don't burn the + remaining rounds: escalate to human immediately with the diagnosis. Lives in + the verify-result handler (E5), alongside the round cap. +7. **Deterministic-first verification.** The article: verification must be + deterministic (tests/type-check) OR a separate evaluator — never agent + self-assessment alone. Our verify worker IS the separate evaluator (good), but + the orchestrator-verify skill now instructs: for any criterion checkable by a + command (tests pass, build green, lint/type clean), RUN it and judge on the + real exit/result; reserve LLM judgment for genuinely subjective criteria. A + criterion marked PASS on a deterministic check is more trustworthy than + "looks done". + +These cost almost nothing relative to the loop and close the two failure modes +the article stresses most (silent failure + weak verification). The three +runaway ceilings (max_verify_rounds + budget breaker + retry cap) stay; the +no-progress detector is a FOURTH, earlier guard. diff --git a/electron-builder.yml b/electron-builder.yml index 1a111dfa4..2e39b6333 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -69,5 +69,5 @@ rpm: npmRebuild: false publish: provider: github - owner: fathah - repo: hermes-desktop + owner: BAS-More + repo: hermes-desktop-Working- diff --git a/package-lock.json b/package-lock.json index 7dbed1c41..0c695f67d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hermes-desktop", - "version": "0.5.9", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hermes-desktop", - "version": "0.5.9", + "version": "0.6.2", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.2", @@ -127,7 +127,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -502,7 +501,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -526,7 +524,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1008,6 +1005,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1029,6 +1027,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1045,6 +1044,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1059,6 +1059,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2105,7 +2106,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2234,7 +2234,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2512,7 +2511,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2901,7 +2899,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -3513,7 +3510,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4028,7 +4024,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4356,7 +4351,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4390,7 +4384,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4401,7 +4394,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4444,7 +4436,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -4543,7 +4534,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -4806,7 +4796,6 @@ "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", @@ -5320,7 +5309,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -5472,7 +5460,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5506,7 +5493,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6178,7 +6164,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6860,7 +6845,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -6960,8 +6946,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/data-urls": { "version": "5.0.0", @@ -7283,7 +7268,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -7468,7 +7452,6 @@ "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -7720,6 +7703,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7740,6 +7724,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8081,7 +8066,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8142,7 +8126,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9540,7 +9523,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -10384,7 +10366,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -10547,7 +10528,6 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -12208,6 +12188,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12984,6 +12965,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13001,6 +12983,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13088,7 +13071,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13360,7 +13342,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13370,7 +13351,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13836,6 +13816,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13866,7 +13847,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14930,6 +14910,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14991,8 +14972,7 @@ "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -15420,7 +15400,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15730,7 +15709,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16320,7 +16298,6 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", @@ -16845,7 +16822,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 63bd73e9b..061040528 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-desktop", - "version": "0.6.1", + "version": "0.6.5", "description": "Hermes Agent Desktop — self-improving AI assistant", "main": "./out/main/index.js", "author": "fathah", @@ -84,4 +84,4 @@ "vite": "^7.2.6", "vitest": "^4.1.4" } -} \ No newline at end of file +} diff --git a/src/main/config-health.test.ts b/src/main/config-health.test.ts index 111a4dd36..aacd96589 100644 --- a/src/main/config-health.test.ts +++ b/src/main/config-health.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; // Mock every config dependency the audit checks touch. The tests then // directly call runConfigHealthCheck (the entry point the renderer hits @@ -49,6 +52,37 @@ let FAKE_VAULT: Record = {}; // the .env overlay was silently skipped — which is exactly what let the // vault-wins precedence inversion hide (AIR-008). A precedence test sets this. let FAKE_ENV: Record = {}; +const ENV_KEYS = [ + "API_SERVER_KEY", + "NANO_GPT_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", + "CUSTOM_API_KEY", + "OPENAI_API_KEY", +] as const; +const ORIGINAL_ENV = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); +const TEST_HOME = join(tmpdir(), "hermes-config-health-unit"); + +vi.mock("./installer", () => ({ + // HERMES_HOME must match the real TEST_HOME the beforeEach writes config.yaml + // and .env into, because config-health resolves paths via the REAL + // profilePaths(./utils) → profileHome → join(HERMES_HOME, ...) and checks them + // with the REAL existsSync (the "fs" mock in this file does not intercept + // config-health's import — verified: its existsSync is never called). On Linux + // a hardcoded "/tmp/hermes-config-health-unit" happened to equal + // join(tmpdir(), ...); on Windows tmpdir() is %TEMP% and join() yields + // backslashes, so the paths diverged and existsSync(configFile) returned false + // — silently gating off EMPTY_API_SERVER_KEY. Deriving from tmpdir() keeps the + // mock and the on-disk fixture in lockstep on every platform. + HERMES_HOME: join(tmpdir(), "hermes-config-health-unit"), + expectedEnvKeyForModel: (provider: string, baseUrl?: string) => { + if (provider === "anthropic") return "ANTHROPIC_API_KEY"; + if (provider === "custom" && String(baseUrl).includes("api.openai.com")) { + return "OPENAI_API_KEY"; + } + return null; + }, +})); vi.mock("./secrets", async () => { const actual = await vi.importActual("./secrets"); @@ -131,20 +165,26 @@ describe("config-health audit — vault awareness", () => { }); mockedCustomEndpointKeyResolvable.mockReturnValue(false); mockedHasOAuthCredentials.mockReturnValue(false); + + for (const key of ENV_KEYS) { + delete process.env[key]; + } + + mkdirSync(TEST_HOME, { recursive: true }); + writeFileSync(join(TEST_HOME, "config.yaml"), ""); + writeFileSync(join(TEST_HOME, ".env"), ""); }); afterEach(() => { - // Don't leak process.env from one test to the next. - for (const k of [ - "API_SERVER_KEY", - "NANO_GPT_API_KEY", - "ANTHROPIC_API_KEY", - "ANTHROPIC_TOKEN", - "CUSTOM_API_KEY", - "OPENAI_API_KEY", - ]) { - delete process.env[k]; + // Don't leak process.env from one test to the next, and restore any + // ambient runner values so the file is hermetic on CI. + for (const key of ENV_KEYS) { + const original = ORIGINAL_ENV.get(key); + if (original == null) delete process.env[key]; + else process.env[key] = original; } + + rmSync(TEST_HOME, { recursive: true, force: true }); }); describe("env provider (default) — byte-for-byte unchanged", () => { diff --git a/src/main/council-advisor.ts b/src/main/council-advisor.ts new file mode 100644 index 000000000..ba8714b62 --- /dev/null +++ b/src/main/council-advisor.ts @@ -0,0 +1,192 @@ +// Council model advisor metadata — powers the "best model for the job" panel +// (item 2e). Provides, per model: a speed tier, an accuracy/quality tier, and +// a free/paid flag, plus a small recommender that ranks the pool for a task. +// +// HONESTY NOTE: accuracy and speed are presented as TIERS, never as fabricated +// percentages. The seed tiers below are a maintained heuristic; the live UI +// further annotates speed with MEASURED latency from real council runs when +// available, and the accuracy tier is nudged by the user's thumbs up/down +// we never invent a precise number we can't back. + +import type { + CouncilTier as Tier, + CouncilSpeedTier as SpeedTier, + CouncilModelAdvice as ModelAdvice, + CouncilAdviceResult as AdviceResult, +} from "../shared/council"; + +export type { Tier, SpeedTier, ModelAdvice, AdviceResult }; + +// Seed catalog drawn from PAL's live roster (listmodels). Free = no per-token +// cost to the user (Groq free tier / Google free tier / open-weight). Paid = +// metered cloud (OpenAI, Anthropic, premium Gemini/Grok via OpenRouter). +export const MODEL_ADVICE: ModelAdvice[] = [ + // ---- Free panel models ------------------------------------------------- + { + model: "gpt-oss-120b", + label: "GPT-OSS 120B", + free: true, + accuracy: "strong", + speed: "fast", + contextK: 131, + strength: "large open reasoning model; strong structured analysis and architecture", + }, + { + model: "qwen3-32b", + label: "Qwen3 32B", + free: true, + accuracy: "strong", + speed: "fast", + contextK: 131, + strength: "strong reasoning + coding for a free model", + }, + { + model: "gemini-2.5-flash", + label: "Gemini 2.5 Flash", + free: true, + accuracy: "strong", + speed: "blazing", + contextK: 1000, + strength: "very fast, huge 1M context, thinking mode — great quick second opinion", + }, + { + model: "llama-3.3-70b-versatile", + label: "Llama 3.3 70B", + free: true, + accuracy: "strong", + speed: "fast", + contextK: 131, + strength: "solid general-purpose critical analysis, Groq-fast", + }, + { + model: "llama-3.1-8b-instant", + label: "Llama 3.1 8B Instant", + free: true, + accuracy: "fair", + speed: "blazing", + contextK: 131, + strength: "near-instant lightweight takes; best for quick sanity checks", + }, + { + model: "gemini-2.5-pro", + label: "Gemini 2.5 Pro", + free: true, + accuracy: "excellent", + speed: "moderate", + contextK: 1000, + strength: "deep reasoning, 1M context — heavyweight free analysis", + }, + // ---- Paid models (advisor surfaces these clearly badged) --------------- + { + model: "gemini-3-pro-preview", + label: "Gemini 3 Pro", + free: false, + accuracy: "excellent", + speed: "moderate", + contextK: 1000, + strength: "frontier reasoning + thinking, 1M context", + }, + { + model: "gpt-5.2", + label: "GPT-5.2", + free: false, + accuracy: "excellent", + speed: "moderate", + contextK: 400, + strength: "flagship reasoning with configurable thinking effort + vision", + }, + { + model: "gpt-5.1-codex", + label: "GPT-5.1 Codex", + free: false, + accuracy: "excellent", + speed: "moderate", + contextK: 400, + strength: "agentic coding specialization — best for deep code tasks", + }, + { + model: "x-ai/grok-4.1-fast", + label: "Grok 4.1 Fast", + free: false, + accuracy: "strong", + speed: "fast", + contextK: 2000, + strength: "huge 2M context, fast thinking", + }, + { + model: "anthropic/claude-opus-4.5", + label: "Claude Opus 4.5", + free: false, + accuracy: "excellent", + speed: "moderate", + contextK: 200, + strength: "top-tier synthesis & judgement — ideal chairman/orchestrator", + }, +]; + +// Task-kind → which strengths to favour. Used by the recommender to bias the +// ranking. Keys are deliberately broad and match the Council tab's task picker. +const TASK_BIAS: Record = { + architecture: { accuracy: 0.8, speed: 0.2, keywords: ["architecture", "reasoning", "design"] }, + coding: { accuracy: 0.7, speed: 0.3, keywords: ["coding", "code"] }, + security: { accuracy: 0.85, speed: 0.15, keywords: ["analysis", "reasoning"] }, + uiux: { accuracy: 0.6, speed: 0.4, keywords: ["reasoning", "context"] }, + "quick-check": { accuracy: 0.3, speed: 0.7, keywords: ["fast", "instant", "quick"] }, + research: { accuracy: 0.8, speed: 0.2, keywords: ["context", "reasoning"] }, + general: { accuracy: 0.6, speed: 0.4, keywords: ["general", "reasoning"] }, +}; + +const ACCURACY_SCORE: Record = { + excellent: 1.0, + strong: 0.75, + fair: 0.5, + basic: 0.25, +}; +const SPEED_SCORE: Record = { + blazing: 1.0, + fast: 0.75, + moderate: 0.5, + slow: 0.25, +}; + +/** + * Rank the model pool for a task kind. `preferFree` (default true) gently + * boosts free models so the casual path stays $0; the user can flip it to see + * paid frontier options. Returns the full ranked list (caller slices top-N). + */ +export function recommendModels( + taskKind: string, + opts: { preferFree?: boolean; pool?: ModelAdvice[] } = {}, +): AdviceResult[] { + const preferFree = opts.preferFree ?? true; + const pool = opts.pool ?? MODEL_ADVICE; + const bias = TASK_BIAS[taskKind] || TASK_BIAS.general; + + return pool + .map((m) => { + const acc = ACCURACY_SCORE[m.accuracy]; + const spd = SPEED_SCORE[m.speed]; + let score = acc * bias.accuracy + spd * bias.speed; + // Keyword affinity: nudge models whose strength matches the task. + const kwHit = bias.keywords.some((k) => m.strength.toLowerCase().includes(k)); + if (kwHit) score += 0.1; + // Cost preference: small boost for free, small penalty for paid. + if (preferFree) score += m.free ? 0.08 : -0.08; + score = Math.max(0, Math.min(1, score)); + + const cost = m.free ? "free" : "paid"; + const reason = + `${m.label}: ${m.strength}. Accuracy ${m.accuracy}, speed ${m.speed}, ` + + `${m.contextK >= 1000 ? `${m.contextK / 1000}M` : `${m.contextK}K`} context, ${cost}.`; + return { ...m, score, reason }; + }) + .sort((a, b) => b.score - a.score); +} + +/** Look up advice for a specific model id (exact, then loose contains match). */ +export function adviceForModel(model: string): ModelAdvice | undefined { + return ( + MODEL_ADVICE.find((m) => m.model === model) || + MODEL_ADVICE.find((m) => model.includes(m.model) || m.model.includes(model)) + ); +} diff --git a/src/main/council-config.ts b/src/main/council-config.ts new file mode 100644 index 000000000..ab08dd9cd --- /dev/null +++ b/src/main/council-config.ts @@ -0,0 +1,305 @@ +// LLM Council configuration store (per-profile, JSON-backed). +// +// The Council feature lets the user assemble a panel of models, each bound to a +// named "position" (Senior Architect, Risk Advisor, ...) with an editable, +// self-learning description. The running Hermes agent (Opus 4.8) acts as the +// orchestrator/chairman: it convenes the panel via PAL MCP (free models for the +// seats), gathers each position's opinion, then synthesizes. +// +// Storage: a dedicated `council-config.json` under the profile home. We do NOT +// touch config.yaml — that file is security-gated and agent-write-protected. +// The council roster is pure desktop-side UX state consumed by the agent through +// a convene prompt, so a sidecar JSON is the right home for it. +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { profilePaths, safeWriteFile } from "./utils"; +import type { + CouncilPosition, + CouncilMember, + CouncilConfig, +} from "../shared/council"; + +export type { CouncilPosition, CouncilMember, CouncilConfig }; + +// ---- Types ----------------------------------------------------------------- + +// Eight well-considered starter positions (Avi's set + "Second Opinion"), +// each with a real starter persona so 2d is never a blank box. These mirror +// the role-specialisation discipline of the upstream council but are fully +// user-editable here. +export const DEFAULT_POSITIONS: CouncilPosition[] = [ + { + id: "best-practice", + title: "Best-Practice Advisor", + description: + "You are the Best-Practice Advisor. Evaluate the question against established, widely-accepted industry best practices and conventions. Cite the principle behind each recommendation. Flag where the proposed approach diverges from the norm and whether that divergence is justified.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "second-opinion", + title: "Second Opinion", + description: + "You are the Second Opinion. Independently answer the question without anchoring on any obvious or leading answer. Where you agree with the likely consensus, say why; where you differ, make the strongest case for the alternative. Surface at least one consideration others would miss.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "senior-architect", + title: "Senior Architect", + description: + "You are the Senior Architect. Focus on system design, structure, separation of concerns, scalability, and long-term maintainability. Identify architectural risks and trade-offs. Prefer designs that are simple, composable, and reversible over clever ones.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "senior-uiux", + title: "Senior UI/UX", + description: + "You are the Senior UI/UX advisor. Evaluate from the end-user's perspective: clarity, discoverability, accessibility (a11y), consistency, and friction. Call out confusing flows and propose concrete, user-friendly alternatives. Champion the user who is not in the room.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "senior-security", + title: "Senior Security Advisor", + description: + "You are the Senior Security Advisor. Map the threat surface: authentication, authorization, data exposure, injection, secrets handling, and supply chain. Rate each material risk by likelihood x severity and call out anything irreversible or catastrophic. Recommend the cheapest mitigation that closes the real risk.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "senior-coder", + title: "Senior Coder", + description: + "You are the Senior Coder. Focus on correctness, edge cases, readability, and idiomatic implementation. Point to the specific lines or functions that matter. Prefer the smallest change that fixes the root cause, and name the failure modes a fix must handle.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "cto", + title: "CTO", + description: + "You are the CTO. Weigh the decision against technical strategy, team capability, build-vs-buy, delivery risk, and total cost of ownership. Be decisive: state what you would do and the single most important reason. Distinguish one-way-door decisions from reversible ones.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, + { + id: "coo", + title: "COO", + description: + "You are the COO. Focus on execution, operations, process, timeline, and resourcing. Identify what could derail delivery and the operational cost of each option. Translate the technical choice into business impact and a concrete next step.", + builtin: true, + upvotes: 0, + downvotes: 0, + }, +]; + +// The recommended free-model panel, proven available on PAL. Members start +// unassigned; the user binds them to positions in the Council tab. +export const DEFAULT_MEMBERS: CouncilMember[] = [ + { id: "m-gptoss", model: "gpt-oss-120b", label: "GPT-OSS 120B", free: true, positionId: "senior-architect" }, + { id: "m-qwen", model: "qwen3-32b", label: "Qwen3 32B", free: true, positionId: "senior-coder" }, + { id: "m-flash", model: "gemini-2.5-flash", label: "Gemini 2.5 Flash", free: true, positionId: "second-opinion" }, + { id: "m-llama70", model: "llama-3.3-70b-versatile", label: "Llama 3.3 70B", free: true, positionId: "senior-security" }, +]; + +export const CHAIRMAN_DEFAULT = "opus-4.8"; + +function defaultConfig(): CouncilConfig { + return { + version: 1, + chairman: CHAIRMAN_DEFAULT, + members: DEFAULT_MEMBERS.map((m) => ({ ...m })), + positions: DEFAULT_POSITIONS.map((p) => ({ ...p })), + }; +} + +// ---- Storage --------------------------------------------------------------- + +function councilFile(profile?: string): string { + const { home } = profilePaths(profile); + return join(home, "council-config.json"); +} + +/** Read the council config, seeding defaults on first run or on a corrupt file. */ +export function getCouncilConfig(profile?: string): CouncilConfig { + const file = councilFile(profile); + if (!existsSync(file)) return defaultConfig(); + try { + const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial; + // Defensive merge: never trust the file to be complete. + return { + version: typeof raw.version === "number" ? raw.version : 1, + chairman: raw.chairman || CHAIRMAN_DEFAULT, + members: Array.isArray(raw.members) ? (raw.members as CouncilMember[]) : [], + positions: Array.isArray(raw.positions) + ? (raw.positions as CouncilPosition[]) + : DEFAULT_POSITIONS.map((p) => ({ ...p })), + }; + } catch { + // Corrupt JSON → fall back to defaults rather than throwing into the UI. + return defaultConfig(); + } +} + +export function saveCouncilConfig(cfg: CouncilConfig, profile?: string): void { + safeWriteFile(councilFile(profile), JSON.stringify(cfg, null, 2) + "\n"); +} + +export function resetCouncilConfig(profile?: string): CouncilConfig { + const cfg = defaultConfig(); + saveCouncilConfig(cfg, profile); + return cfg; +} + +// ---- Mutations (each returns the updated config) --------------------------- + +function uid(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} + +export function addCouncilMember( + member: { model: string; label?: string; free?: boolean; positionId?: string | null }, + profile?: string, +): CouncilConfig { + const cfg = getCouncilConfig(profile); + // De-dupe by model id — one enrolment per model. + if (!cfg.members.some((m) => m.model === member.model)) { + cfg.members.push({ + id: uid("m"), + model: member.model, + label: member.label || member.model, + free: member.free ?? false, + positionId: member.positionId ?? null, + }); + saveCouncilConfig(cfg, profile); + } + return cfg; +} + +export function removeCouncilMember(memberId: string, profile?: string): CouncilConfig { + const cfg = getCouncilConfig(profile); + cfg.members = cfg.members.filter((m) => m.id !== memberId); + saveCouncilConfig(cfg, profile); + return cfg; +} + +export function assignMemberPosition( + memberId: string, + positionId: string | null, + profile?: string, +): CouncilConfig { + const cfg = getCouncilConfig(profile); + const m = cfg.members.find((x) => x.id === memberId); + if (m) { + m.positionId = positionId; + saveCouncilConfig(cfg, profile); + } + return cfg; +} + +export function setChairman(model: string, profile?: string): CouncilConfig { + const cfg = getCouncilConfig(profile); + cfg.chairman = model || CHAIRMAN_DEFAULT; + saveCouncilConfig(cfg, profile); + return cfg; +} + +export function upsertPosition( + pos: { id?: string; title: string; description: string }, + profile?: string, +): CouncilConfig { + const cfg = getCouncilConfig(profile); + if (pos.id) { + const existing = cfg.positions.find((p) => p.id === pos.id); + if (existing) { + existing.title = pos.title; + existing.description = pos.description; + // A manual edit clears any pending self-learning proposal. + existing.proposedDescription = undefined; + } + } else { + cfg.positions.push({ + id: uid("pos"), + title: pos.title, + description: pos.description, + builtin: false, + upvotes: 0, + downvotes: 0, + }); + } + saveCouncilConfig(cfg, profile); + return cfg; +} + +export function deletePosition(positionId: string, profile?: string): CouncilConfig { + const cfg = getCouncilConfig(profile); + const pos = cfg.positions.find((p) => p.id === positionId); + // Built-in seats are protected from deletion (still editable). + if (pos && !pos.builtin) { + cfg.positions = cfg.positions.filter((p) => p.id !== positionId); + // Unbind any members that filled it. + for (const m of cfg.members) if (m.positionId === positionId) m.positionId = null; + saveCouncilConfig(cfg, profile); + } + return cfg; +} + +/** Record a thumbs up/down against a position. Feeds the self-learning loop: + * the agent reads these tallies to propose a refined description. */ +export function recordPositionFeedback( + positionId: string, + vote: "up" | "down", + profile?: string, +): CouncilConfig { + const cfg = getCouncilConfig(profile); + const pos = cfg.positions.find((p) => p.id === positionId); + if (pos) { + if (vote === "up") pos.upvotes += 1; + else pos.downvotes += 1; + saveCouncilConfig(cfg, profile); + } + return cfg; +} + +/** Stage an agent-proposed description refinement for the user to accept. */ +export function proposePositionDescription( + positionId: string, + proposed: string, + profile?: string, +): CouncilConfig { + const cfg = getCouncilConfig(profile); + const pos = cfg.positions.find((p) => p.id === positionId); + if (pos) { + pos.proposedDescription = proposed; + saveCouncilConfig(cfg, profile); + } + return cfg; +} + +/** Accept (or reject) a staged description refinement. */ +export function resolveProposedDescription( + positionId: string, + accept: boolean, + profile?: string, +): CouncilConfig { + const cfg = getCouncilConfig(profile); + const pos = cfg.positions.find((p) => p.id === positionId); + if (pos && pos.proposedDescription) { + if (accept) pos.description = pos.proposedDescription; + pos.proposedDescription = undefined; + // Reset the tally after a learning cycle so the next refinement is fresh. + pos.upvotes = 0; + pos.downvotes = 0; + saveCouncilConfig(cfg, profile); + } + return cfg; +} diff --git a/src/main/hermes-auth.ts b/src/main/hermes-auth.ts index c6cd9a161..182e3e7c3 100644 --- a/src/main/hermes-auth.ts +++ b/src/main/hermes-auth.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcess } from "child_process"; +import { spawn, execFile, type ChildProcess } from "child_process"; +import { randomBytes, createHash } from "crypto"; import { homedir } from "os"; import { HERMES_PYTHON, @@ -8,7 +9,7 @@ import { getEnhancedPath, } from "./installer"; import { HIDDEN_SUBPROCESS_OPTIONS } from "./process-options"; -import { stripAnsi } from "./utils"; +import { profileHome, stripAnsi } from "./utils"; /** * Provider identifiers that authenticate via an interactive OAuth flow @@ -171,3 +172,253 @@ export function cancelHermesAuthLogin(): boolean { activeProc.kill(); return true; } + +// === Anthropic Claude (OAuth) — native PKCE flow === +// +// Anthropic's OAuth is a paste-a-code PKCE flow: the user authorizes in +// the browser and is handed a `#` string to paste back. The +// CLI's `auth add` path can't drive this here because it ignores the +// closed stdin we hand it and refuses a non-tty stdin. So we run the PKCE +// dance natively in this Node main process, then persist the resulting +// token into the engine's credential pool via a non-interactive Python +// one-liner. Constants mirror hermes-agent/agent/anthropic_adapter.py. + +const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const ANTHROPIC_AUTHORIZE_BASE = "https://claude.ai/oauth/authorize"; +const ANTHROPIC_REDIRECT_URI = + "https://console.anthropic.com/oauth/code/callback"; +const ANTHROPIC_SCOPE = "org:create_api_key user:profile user:inference"; +const ANTHROPIC_TOKEN_ENDPOINTS = [ + "https://platform.claude.com/v1/oauth/token", + "https://console.anthropic.com/v1/oauth/token", +]; +const ANTHROPIC_USER_AGENT = "claude-cli/2.1.74 (external, cli)"; + +function base64UrlNoPad(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** + * Generate a fresh PKCE verifier/challenge + state and build the Anthropic + * authorize URL. The caller must hold onto `verifier` and `state` to pass + * back into {@link exchangeAnthropicCode} when the user submits the pasted + * code. + */ +export function buildAnthropicAuthUrl(): { + url: string; + verifier: string; + state: string; +} { + const verifier = base64UrlNoPad(randomBytes(32)); + const challenge = base64UrlNoPad( + createHash("sha256").update(verifier).digest(), + ); + const state = base64UrlNoPad(randomBytes(32)); + + const params = new URLSearchParams({ + code: "true", + client_id: ANTHROPIC_CLIENT_ID, + response_type: "code", + redirect_uri: ANTHROPIC_REDIRECT_URI, + scope: ANTHROPIC_SCOPE, + code_challenge: challenge, + code_challenge_method: "S256", + state, + }); + + return { + url: `${ANTHROPIC_AUTHORIZE_BASE}?${params.toString()}`, + verifier, + state, + }; +} + +interface AnthropicTokenResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; +} + +/** + * POST the authorization code to Anthropic's token endpoint, trying the + * primary `platform.claude.com` host first and falling back to + * `console.anthropic.com`. Returns the parsed token payload or throws with + * the last error encountered. + */ +async function postAnthropicToken( + code: string, + state: string, + verifier: string, +): Promise { + const body = JSON.stringify({ + grant_type: "authorization_code", + client_id: ANTHROPIC_CLIENT_ID, + code, + state, + redirect_uri: ANTHROPIC_REDIRECT_URI, + code_verifier: verifier, + }); + + let lastError = ""; + for (const endpoint of ANTHROPIC_TOKEN_ENDPOINTS) { + try { + const resp = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": ANTHROPIC_USER_AGENT, + }, + body, + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + lastError = `${endpoint} → HTTP ${resp.status} ${text.slice(0, 200)}`; + continue; + } + return (await resp.json()) as AnthropicTokenResponse; + } catch (err) { + lastError = `${endpoint} → ${(err as Error).message}`; + } + } + throw new Error(lastError || "Token exchange failed."); +} + +// Persists the freshly-minted Anthropic token into the engine's +// credential pool by appending a new entry (each call adds an account, +// so repeated sign-ins build up multi-account support automatically). +const ANTHROPIC_PERSIST_PYCODE = `import sys,uuid,time +from agent.credential_pool import PooledCredential, AUTH_TYPE_OAUTH, SOURCE_MANUAL, label_from_token, load_pool +from hermes_cli.auth_commands import _oauth_default_label, _provider_base_url +at,rt,ein=sys.argv[1],sys.argv[2],int(sys.argv[3]) +pool=load_pool("anthropic"); n=len(pool.entries())+1 +lab=label_from_token(at,_oauth_default_label("anthropic",n)) +pool.add_entry(PooledCredential(provider="anthropic",id=uuid.uuid4().hex[:6],label=lab,auth_type=AUTH_TYPE_OAUTH,priority=0,source=f"{SOURCE_MANUAL}:desktop_pkce",access_token=at,refresh_token=(rt or None),expires_at_ms=int(time.time()*1000)+ein*1000,base_url=_provider_base_url("anthropic"))) +print("OK "+lab)`; + +function persistAnthropicToken( + accessToken: string, + refreshToken: string, + expiresInSeconds: number, + profile?: string, +): Promise<{ ok: boolean; output: string }> { + // The hermes-agent engine resolves its data home purely from the + // HERMES_HOME env var (see hermes_constants.get_hermes_home — it reads + // os.environ["HERMES_HOME"] and has no HERMES_PROFILE support). So to + // make load_pool() write into the SELECTED profile's credential pool + // rather than the default one, we point HERMES_HOME at that profile's + // home (/profiles/) for non-default profiles. The + // default profile keeps the unmodified HERMES_HOME to avoid regressions. + const persistHome = + profile && profile !== "default" ? profileHome(profile) : HERMES_HOME; + return new Promise((resolve) => { + execFile( + HERMES_PYTHON, + [ + "-c", + ANTHROPIC_PERSIST_PYCODE, + accessToken, + refreshToken, + String(expiresInSeconds), + ], + { + cwd: HERMES_REPO, + env: { + ...process.env, + PATH: getEnhancedPath(), + HOME: homedir(), + HERMES_HOME: persistHome, + PYTHONUNBUFFERED: "1", + }, + ...HIDDEN_SUBPROCESS_OPTIONS, + }, + (err, stdout, stderr) => { + const out = `${stdout || ""}${stderr || ""}`; + if (out.includes("OK ")) { + resolve({ ok: true, output: out }); + } else { + resolve({ + ok: false, + output: err ? `${err.message}\n${out}` : out, + }); + } + }, + ); + }); +} + +/** + * Complete the Anthropic OAuth flow: split the pasted `#` + * string, exchange the code for tokens, then persist them into the + * credential pool. Returns `{ success: true, persisted: true }` once the + * Python persistence prints "OK ", otherwise a failure with the reason. + */ +export async function exchangeAnthropicCode( + pasted: string, + verifier: string, + state: string, + profile?: string, +): Promise { + const trimmed = (pasted || "").trim(); + if (!trimmed) { + return { success: false, error: "No code provided." }; + } + if (!verifier || !state) { + return { + success: false, + error: "No sign-in in progress — start the flow first.", + }; + } + + const [code, returnedState] = trimmed.split("#"); + if (!code) { + return { success: false, error: "Pasted code is malformed." }; + } + // The browser hands back `#`. We send the state we + // generated (the proven flow passes the original state in the token + // POST); the returned half is just sanity-checked when present. + if (returnedState && returnedState !== state) { + return { + success: false, + error: "State mismatch — please restart the sign-in.", + }; + } + + let tokens: AnthropicTokenResponse; + try { + tokens = await postAnthropicToken(code, state, verifier); + } catch (err) { + return { + success: false, + error: `Token exchange failed: ${(err as Error).message}`, + }; + } + + const accessToken = tokens.access_token; + if (!accessToken) { + return { + success: false, + error: "Token endpoint returned no access_token.", + }; + } + const refreshToken = tokens.refresh_token ?? ""; + const expiresIn = + typeof tokens.expires_in === "number" ? tokens.expires_in : 0; + + const persisted = await persistAnthropicToken( + accessToken, + refreshToken, + expiresIn, + profile, + ); + if (persisted.ok) { + return { success: true, persisted: true }; + } + return { + success: false, + error: `Failed to save credential: ${persisted.output.trim().slice(0, 400)}`, + }; +} diff --git a/src/main/hermes.ts b/src/main/hermes.ts index f3dacde08..672289382 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -20,6 +20,7 @@ import { HERMES_HOME, HERMES_REPO, HERMES_PYTHON, + HERMES_VENV, hermesCliArgs, getEnhancedPath, } from "./installer"; @@ -2767,6 +2768,90 @@ function gatewayLogPath(profile?: string): string { return join(logDir, "gateway-stderr.log"); } +// Local TLS-intercepting proxies (corporate MITM, the 9Router dev proxy, +// Fiddler/mitmproxy, etc.) present a self-signed root CA that the Python +// engine's bundled certifi store doesn't trust. The Node side of the app +// is unaffected because Electron is launched with NODE_EXTRA_CA_CERTS, but +// the gateway is a separate Python process whose httpx/requests/SDK calls +// fail with `CERTIFICATE_VERIFY_FAILED` against every upstream (Anthropic, +// Gemini, OpenAI, MCP servers). Symptom: "API call failed after 3 retries: +// Streaming request failed: [SSL: CERTIFICATE_VERIFY_FAILED]". +// +// Candidate proxy-CA locations, in priority order. NODE_EXTRA_CA_CERTS (set +// by the same proxies that need this fix) is checked first so we reuse +// whatever Electron already trusts. +function proxyCaCandidates(): string[] { + const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); + const candidates = [ + process.env.NODE_EXTRA_CA_CERTS, + join(appData, "9router", "mitm", "rootCA.crt"), + ]; + return candidates.filter((p): p is string => !!p && existsSync(p)); +} + +// The engine resolves its CA bundle from HERMES_CA_BUNDLE / SSL_CERT_FILE / +// REQUESTS_CA_BUNDLE (see hermes-agent/agent/model_metadata.py). When a +// local proxy CA exists and the user hasn't already pointed those vars +// somewhere, build a combined bundle (certifi + the proxy CA) under +// HERMES_HOME and return the env vars that make every Python TLS callsite +// trust it. Returns {} when there's nothing to do — no proxy CA, or the +// user already set a bundle — so default installs are completely unaffected. +function resolveProxyCaEnv(): Record { + // Respect an explicit user/operator override — never clobber it. + if ( + process.env.HERMES_CA_BUNDLE || + process.env.SSL_CERT_FILE || + process.env.REQUESTS_CA_BUNDLE + ) { + return {}; + } + + const proxyCa = proxyCaCandidates()[0]; + if (!proxyCa) return {}; + + const certifiPem = join( + HERMES_VENV, + "Lib", + "site-packages", + "certifi", + "cacert.pem", + ); + if (!existsSync(certifiPem)) return {}; + + const combined = join(HERMES_HOME, "ca-bundle-combined.pem"); + try { + const base = readFileSync(certifiPem, "utf8"); + const extra = readFileSync(proxyCa, "utf8"); + // Regenerate only when missing or stale so we don't churn the file on + // every gateway start. Staleness keys on the proxy CA's bytes. + const marker = `# proxy-ca:${Buffer.byteLength(extra)}`; + const needsWrite = + !existsSync(combined) || !readFileSync(combined, "utf8").includes(marker); + if (needsWrite) { + writeFileSync( + combined, + `${base}\n${marker}\n# === Local TLS proxy CA (auto-added by Hermes Desktop) ===\n${extra}\n`, + "utf8", + ); + console.log( + `[gateway] Trusting local TLS proxy CA (${proxyCa}) via ${combined}`, + ); + } + } catch (err) { + console.warn( + `[gateway] Could not build combined CA bundle: ${(err as Error).message}`, + ); + return {}; + } + + return { + HERMES_CA_BUNDLE: combined, + SSL_CERT_FILE: combined, + REQUESTS_CA_BUNDLE: combined, + CURL_CA_BUNDLE: combined, + }; +} + export function buildGatewayEnv(profile?: string): Record { // Make sure this profile's config.yaml enables the api_server and binds the // profile's own port before we spawn. @@ -2783,6 +2868,11 @@ export function buildGatewayEnv(profile?: string): Record { // present (getProfilePort keeps it collision-free); this env value covers // the case where the block exists but omits an explicit port. API_SERVER_PORT: String(port), + // Trust a local TLS-intercepting proxy's CA so the Python gateway's + // httpx/requests/SDK calls don't fail with CERTIFICATE_VERIFY_FAILED. + // No-op ({}) on machines without such a proxy. Spread before the + // profile-env loop below so a user's explicit .env value still wins. + ...resolveProxyCaEnv(), }; // Inject ALL profile API keys so the gateway can authenticate with any provider. diff --git a/src/main/index.ts b/src/main/index.ts index ec4d556df..c4d4a5102 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,8 @@ import { runHermesAuthLogin, cancelHermesAuthLogin, detectDeviceCode, + buildAnthropicAuthUrl, + exchangeAnthropicCode, } from "./hermes-auth"; import { isRemoteMode, @@ -134,17 +136,48 @@ import { setAuxiliaryTask, resetAuxiliaryToAuto, } from "./auxiliary-config"; +import { + getCouncilConfig, + resetCouncilConfig, + addCouncilMember, + removeCouncilMember, + assignMemberPosition, + setChairman as setCouncilChairman, + upsertPosition, + deletePosition, + recordPositionFeedback, + proposePositionDescription, + resolveProposedDescription, +} from "./council-config"; +import { recommendModels, MODEL_ADVICE } from "./council-advisor"; import { listSessions, getSessionMessages, searchSessions, deleteSession, deleteSessions, + listAllSessions, + searchAllSessions, + getSessionMessagesFromProfile, + deleteSessionInProfile, + deleteSessionsByProfile, + renameSessionInProfile, + setSessionArchived, + setSessionPinned, + setSessionStatus, + moveSessionToGroup, + listAllSessionGroups, + createSessionGroup, + deleteSessionGroup, + type SessionStatus, } from "./sessions"; import { syncSessionCache, listCachedSessions, updateSessionTitle, + syncAllSessionCaches, + updateSessionTitleInProfile, + removeSessionFromProfileCache, } from "./session-cache"; import { listModels, addModel, removeModel, updateModel } from "./models"; import { validateChatReadiness } from "./validation"; @@ -218,6 +251,11 @@ import { switchBoard as kanbanSwitchBoard, createBoard as kanbanCreateBoard, removeBoard as kanbanRemoveBoard, + governStatus as kanbanGovernStatus, + governSet as kanbanGovernSet, + governKillSwitch as kanbanGovernKillSwitch, + governModels as kanbanGovernModels, + type GovernSetChange, listTasks as kanbanListTasks, getTask as kanbanGetTask, createTask as kanbanCreateTask, @@ -595,6 +633,32 @@ function setupIPC(): void { }); ipcMain.handle("oauth-login-cancel", () => cancelHermesAuthLogin()); + // Anthropic Claude (OAuth) — native paste-a-code PKCE flow. The CLI + // can't drive this here (it refuses our closed/non-tty stdin), so the + // main process runs the PKCE dance itself. `anthropic-oauth-start` + // mints a fresh verifier/state pair (single-flight) and returns the + // authorize URL; `anthropic-oauth-submit` exchanges the pasted code + // and persists the token into the credential pool. + let pendingAnthropic: { verifier: string; state: string } = { + verifier: "", + state: "", + }; + ipcMain.handle("anthropic-oauth-start", () => { + const r = buildAnthropicAuthUrl(); + pendingAnthropic = { verifier: r.verifier, state: r.state }; + return { url: r.url }; + }); + ipcMain.handle( + "anthropic-oauth-submit", + (_event, code: string, profile?: string) => + exchangeAnthropicCode( + code, + pendingAnthropic.verifier, + pendingAnthropic.state, + profile, + ), + ); + // Configuration (profile-aware) ipcMain.handle("get-locale", () => getAppLocale()); ipcMain.handle("set-locale", (_event, locale: AppLocale) => @@ -795,6 +859,69 @@ function setupIPC(): void { return true; }); + // ---- LLM Council config (desktop-side roster; consumed by the agent via a + // convene prompt that calls PAL MCP). Stored in council-config.json, NOT + // config.yaml — no gateway restart needed since the agent reads it per run. + ipcMain.handle("council-get-config", (_event, profile?: string) => + getCouncilConfig(profile), + ); + ipcMain.handle("council-reset-config", (_event, profile?: string) => + resetCouncilConfig(profile), + ); + ipcMain.handle( + "council-add-member", + ( + _event, + member: { model: string; label?: string; free?: boolean; positionId?: string | null }, + profile?: string, + ) => addCouncilMember(member, profile), + ); + ipcMain.handle("council-remove-member", (_event, memberId: string, profile?: string) => + removeCouncilMember(memberId, profile), + ); + ipcMain.handle( + "council-assign-position", + (_event, memberId: string, positionId: string | null, profile?: string) => + assignMemberPosition(memberId, positionId, profile), + ); + ipcMain.handle("council-set-chairman", (_event, model: string, profile?: string) => + setCouncilChairman(model, profile), + ); + ipcMain.handle( + "council-upsert-position", + ( + _event, + pos: { id?: string; title: string; description: string }, + profile?: string, + ) => upsertPosition(pos, profile), + ); + ipcMain.handle("council-delete-position", (_event, positionId: string, profile?: string) => + deletePosition(positionId, profile), + ); + ipcMain.handle( + "council-position-feedback", + (_event, positionId: string, vote: "up" | "down", profile?: string) => + recordPositionFeedback(positionId, vote, profile), + ); + ipcMain.handle( + "council-propose-description", + (_event, positionId: string, proposed: string, profile?: string) => + proposePositionDescription(positionId, proposed, profile), + ); + ipcMain.handle( + "council-resolve-description", + (_event, positionId: string, accept: boolean, profile?: string) => + resolveProposedDescription(positionId, accept, profile), + ); + // Advisor: rank the model pool for a task kind (item 2e). Pure/static + + // feedback-nudged tiers; no network call, returns instantly. + ipcMain.handle( + "council-recommend-models", + (_event, taskKind: string, preferFree?: boolean) => + recommendModels(taskKind, { preferFree: preferFree ?? true }), + ); + ipcMain.handle("council-model-advice", () => MODEL_ADVICE); + // API_SERVER_KEY management — lets the renderer detect a missing key and // generate one with a button click (local mode) or show instructions (remote/SSH). // Additive shape: `hasKey` stays the required primary field; `providerId` / @@ -1644,6 +1771,100 @@ function setupIPC(): void { return searchSessions(query, limit); }); + // --- Multi-profile session aggregation + desktop session metadata --- + // SSH remains single-profile: fall back to the existing remote helpers so + // the aggregator routes degrade gracefully under a pure-HTTP/SSH connection. + ipcMain.handle("list-all-sessions", (_event, limit?: number) => { + const conn = getConnectionConfig(); + if (conn.mode === "ssh" && conn.ssh) + return sshListCachedSessions(conn.ssh, limit ?? 200); + return listAllSessions(limit); + }); + ipcMain.handle("sync-all-session-caches", () => { + const conn = getConnectionConfig(); + if (conn.mode === "ssh" && conn.ssh) + return sshListCachedSessions(conn.ssh, 200); + try { + syncAllSessionCaches(); + } catch (error) { + console.error("sync-all-session-caches failed", error); + } + return listAllSessions(200); + }); + ipcMain.handle( + "search-all-sessions", + (_event, query: string, limit?: number) => { + const conn = getConnectionConfig(); + if (conn.mode === "ssh" && conn.ssh) + return sshSearchSessions(conn.ssh, query, limit); + return searchAllSessions(query, limit); + }, + ); + ipcMain.handle( + "get-session-messages-from-profile", + (_event, profile: string, sessionId: string) => { + const conn = getConnectionConfig(); + if (conn.mode === "ssh" && conn.ssh) + return sshGetSessionMessages(conn.ssh, sessionId); + return getSessionMessagesFromProfile(profile, sessionId); + }, + ); + ipcMain.handle( + "delete-session-in-profile", + (_event, profile: string, sessionId: string) => { + deleteSessionInProfile(profile, sessionId); + removeSessionFromProfileCache(profile, sessionId); + }, + ); + ipcMain.handle( + "delete-sessions-by-profile", + (_event, byProfile: Record) => { + const result = deleteSessionsByProfile(byProfile || {}); + for (const [profile, ids] of Object.entries(byProfile || {})) { + for (const id of ids) removeSessionFromProfileCache(profile, id); + } + return result; + }, + ); + ipcMain.handle( + "rename-session", + (_event, profile: string, sessionId: string, title: string) => { + renameSessionInProfile(profile, sessionId, title); + updateSessionTitleInProfile(profile, sessionId, title); + }, + ); + ipcMain.handle( + "set-session-archived", + (_event, profile: string, sessionId: string, archived: boolean) => + setSessionArchived(profile, sessionId, archived), + ); + ipcMain.handle( + "set-session-pinned", + (_event, profile: string, sessionId: string, pinned: boolean) => + setSessionPinned(profile, sessionId, pinned), + ); + ipcMain.handle( + "set-session-status", + (_event, profile: string, sessionId: string, status: SessionStatus) => + setSessionStatus(profile, sessionId, status), + ); + ipcMain.handle( + "move-session-to-group", + (_event, profile: string, sessionId: string, groupId: string | null) => + moveSessionToGroup(profile, sessionId, groupId), + ); + ipcMain.handle("list-session-groups", () => listAllSessionGroups()); + ipcMain.handle( + "create-session-group", + (_event, profile: string, name: string, color?: string | null) => + createSessionGroup(profile, name, color), + ); + ipcMain.handle( + "delete-session-group", + (_event, profile: string, groupId: string) => + deleteSessionGroup(profile, groupId), + ); + // Credential Pool — profile-aware. When `profile` is omitted, the // credential pool helpers default to the currently active profile's // auth.json (see config.ts:authFilePath), so the renderer can pass an @@ -1813,6 +2034,15 @@ function setupIPC(): void { (_event, includeArchived?: boolean, profile?: string) => kanbanListBoards(includeArchived, profile), ); + // Factory governance + ipcMain.handle("kanban-govern-status", () => kanbanGovernStatus()); + ipcMain.handle("kanban-govern-set", (_event, change: GovernSetChange) => + kanbanGovernSet(change), + ); + ipcMain.handle("kanban-govern-killswitch", (_event, on: boolean) => + kanbanGovernKillSwitch(on), + ); + ipcMain.handle("kanban-govern-models", () => kanbanGovernModels()); ipcMain.handle("kanban-current-board", (_event, profile?: string) => kanbanCurrentBoard(profile), ); @@ -2333,7 +2563,7 @@ app.whenReady().then(() => { "script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com https://*.i.posthog.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob:; " + - "connect-src 'self' blob: https://*.posthog.com https://*.i.posthog.com; " + + "connect-src 'self' blob: https://*.posthog.com https://*.i.posthog.com http://127.0.0.1:8770; " + "media-src 'self' blob:"; callback({ responseHeaders: { diff --git a/src/main/kanban.ts b/src/main/kanban.ts index c4ada58ec..3245b3a20 100644 --- a/src/main/kanban.ts +++ b/src/main/kanban.ts @@ -423,3 +423,81 @@ export async function dispatchOnce( const res = await runKanban(args, { profile, parseJson: true }); return { success: res.success, error: res.error, data: res.data }; } + +// --------------------------------------------------------------------------- +// Factory governance (hermes kanban govern …) +// --------------------------------------------------------------------------- + +/** Full factory governance/budget/orchestration/activity status document. */ +export async function governStatus(): Promise> { + if (isRemoteOnlyMode()) return unsupportedInRemote(); + const res = await runKanban(["govern", "--json"], { parseJson: true }); + if (!res.success) return { success: false, error: res.error }; + return { success: true, data: res.data }; +} + +/** Apply one or more governance/orchestration changes. */ +export interface GovernSetChange { + level?: "monitor" | "warn" | "gate" | "strict"; + secretScan?: "on" | "off"; + addProtected?: string; + removeProtected?: string; + profile?: string; + hybrid?: "on" | "off"; + orchestratorProfile?: string; + defaultAssignee?: string; + autoDecompose?: "on" | "off"; + autoDecomposePerTick?: number; + maxInProgress?: number; + orchestratorLoop?: "on" | "off"; + maxVerifyRounds?: number; + model?: string; + defaultMaxIterations?: number; + defaultWallclock?: number; +} + +export async function governSet( + change: GovernSetChange, +): Promise> { + if (isRemoteOnlyMode()) return unsupportedInRemote(); + const args = ["govern", "set"]; + if (change.level) args.push("--level", change.level); + if (change.secretScan) args.push("--secret-scan", change.secretScan); + if (change.addProtected) args.push("--add-protected", change.addProtected); + if (change.removeProtected) args.push("--remove-protected", change.removeProtected); + // --for-profile, not --profile: hermes's global --profile/-p switches the + // active profile and would be consumed before the kanban subparser sees it. + if (change.profile) args.push("--for-profile", change.profile); + if (change.hybrid) args.push("--hybrid", change.hybrid); + if (change.orchestratorProfile) args.push("--orchestrator-profile", change.orchestratorProfile); + if (change.defaultAssignee) args.push("--default-assignee", change.defaultAssignee); + if (change.autoDecompose) args.push("--auto-decompose", change.autoDecompose); + if (change.autoDecomposePerTick !== undefined) + args.push("--auto-decompose-per-tick", String(change.autoDecomposePerTick)); + if (change.maxInProgress !== undefined) + args.push("--max-in-progress", String(change.maxInProgress)); + if (change.orchestratorLoop) args.push("--orchestrator-loop", change.orchestratorLoop); + if (change.maxVerifyRounds !== undefined) + args.push("--max-verify-rounds", String(change.maxVerifyRounds)); + if (change.model) args.push("--model", change.model); + if (change.defaultMaxIterations !== undefined) + args.push("--default-max-iterations", String(change.defaultMaxIterations)); + if (change.defaultWallclock !== undefined) + args.push("--default-wallclock", String(change.defaultWallclock)); + const res = await runKanban(args, { parseJson: true }); + return { success: res.success, error: res.error, data: res.data }; +} + +/** Toggle the factory kill-switch (STOP sentinel). */ +export async function governKillSwitch(on: boolean): Promise> { + if (isRemoteOnlyMode()) return unsupportedInRemote(); + const res = await runKanban(["govern", "killswitch", on ? "on" : "off"], { parseJson: true }); + return { success: res.success, error: res.error, data: res.data }; +} + +/** Engine-compatible (cc/ + ag/) model list for the per-agent model picker. */ +export async function governModels(): Promise> { + if (isRemoteOnlyMode()) return unsupportedInRemote(); + const res = await runKanban(["govern", "models"], { parseJson: true }); + return { success: res.success, error: res.error, data: res.data }; +} diff --git a/src/main/profiles.ts b/src/main/profiles.ts index 561e8a7e1..1c80ea117 100644 --- a/src/main/profiles.ts +++ b/src/main/profiles.ts @@ -14,6 +14,7 @@ import { isValidNamedProfileName, isValidProfileName, pidIsAliveAs, + writeActiveProfileName, PROFILE_NAME_ERROR, } from "./utils"; import { HIDDEN_SUBPROCESS_OPTIONS } from "./process-options"; @@ -304,4 +305,11 @@ export function setActiveProfile(name: string): void { } catch { // ignore } + + // Defensive mirror: write ~/.hermes/active_profile ourselves regardless of + // whether the CLI above succeeded. A missing active_profile file is exactly + // the regression that made the Sessions tab resolve to the empty default DB + // and hide every named-profile session — so we never rely solely on the CLI + // having written it. + writeActiveProfileName(name); } diff --git a/src/main/secrets/commandProvider.ts b/src/main/secrets/commandProvider.ts index 443c5d40d..342934baf 100644 --- a/src/main/secrets/commandProvider.ts +++ b/src/main/secrets/commandProvider.ts @@ -2,9 +2,51 @@ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding, } from "child_process"; +import { existsSync } from "fs"; import type { SecretsProvider } from "./provider"; import { getConfigValue } from "../config"; +/** + * Resolve a POSIX `sh` to run the helper command with. + * + * The helper command is POSIX shell syntax (the docs/examples use `printf`, + * `keepassxc-cli`, `cat tmpfs`, dotenv dumps), so we always want a POSIX shell — + * NOT cmd.exe/PowerShell. Historically this was the hardcoded absolute path + * `/bin/sh`, which is correct on Linux/macOS but FAILS on Windows: Node's + * `execFileSync` resolves `/bin/sh` against the real Win32 filesystem, where it + * does not exist (the MSYS/Git-Bash `/bin/sh` is a shell mount illusion, not a + * Win32 path), so every spawn returned ENOENT and every key degraded to null. + * + * Resolution order: + * 1. A bare `"sh"` — let the OS resolve it on PATH. On Linux/macOS this is + * `/bin/sh`; on Windows with Git Bash / WSL / Cygwin on PATH this finds a + * real POSIX shell, making the provider work cross-platform. + * 2. Common absolute fallbacks for environments where `sh` isn't on PATH but a + * known shell exists (Git for Windows default install). + * + * Returns `"sh"` as the last resort so the spawn still attempts PATH resolution; + * if no shell exists the spawn fails and the provider degrades to null exactly + * as before (logged), never throwing. + */ +export function resolveShell(): string { + // POSIX: bare "sh" resolves to /bin/sh via PATH. Cheap and correct. + if (process.platform !== "win32") return "sh"; + + // Windows: prefer well-known Git-for-Windows / system shells by absolute + // path (PATH may not include them even when installed), else fall back to + // bare "sh" for WSL/Cygwin setups that DO put it on PATH. + const candidates = [ + process.env.SHELL, + "C:\\Program Files\\Git\\usr\\bin\\sh.exe", + "C:\\Program Files\\Git\\bin\\sh.exe", + "C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe", + ].filter((p): p is string => typeof p === "string" && p.length > 0); + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return "sh"; +} + /** * Hard cap so a hung helper can never wedge a turn. Kept deliberately TIGHT (3s) * because resolution runs synchronously on the Electron MAIN process: a slow or @@ -185,7 +227,7 @@ export class CommandSecretsProvider implements SecretsProvider { if (!command) return null; try { const stdout = execFileSync( - "/bin/sh", + resolveShell(), ["-c", command], helperExecOptions(key), ); @@ -218,7 +260,7 @@ export class CommandSecretsProvider implements SecretsProvider { if (!command) return {}; try { const stdout = execFileSync( - "/bin/sh", + resolveShell(), ["-c", command], helperExecOptions(""), ); diff --git a/src/main/secrets/resolveShell.test.ts b/src/main/secrets/resolveShell.test.ts new file mode 100644 index 000000000..d76e4569d --- /dev/null +++ b/src/main/secrets/resolveShell.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { resolveShell } from "./commandProvider"; + +// resolveShell() is the cross-platform fix for the secrets command provider. +// Historically the provider hardcoded "/bin/sh", which Node's execFileSync +// resolves against the real Win32 filesystem — absent there (the MSYS/Git-Bash +// /bin/sh is a shell-mount illusion, not a Win32 path), so every spawn returned +// ENOENT and every key degraded to null on Windows. resolveShell() returns a +// POSIX sh that actually exists on the host. + +describe("resolveShell", () => { + const realPlatform = process.platform; + const realShell = process.env.SHELL; + + afterEach(() => { + Object.defineProperty(process, "platform", { value: realPlatform }); + if (realShell === undefined) delete process.env.SHELL; + else process.env.SHELL = realShell; + vi.restoreAllMocks(); + }); + + it("returns bare 'sh' on POSIX (PATH-resolved, never a hardcoded path)", () => { + Object.defineProperty(process, "platform", { value: "linux" }); + expect(resolveShell()).toBe("sh"); + }); + + it("returns bare 'sh' on macOS", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + expect(resolveShell()).toBe("sh"); + }); + + it("NEVER returns the literal '/bin/sh' that fails on Win32 execFileSync", () => { + // The exact historical bug: a hardcoded absolute path that ENOENTs on + // Windows. resolveShell must never hand that back on win32. + Object.defineProperty(process, "platform", { value: "win32" }); + delete process.env.SHELL; + const shell = resolveShell(); + expect(shell).not.toBe("/bin/sh"); + }); + + it("on win32 prefers an existing absolute shell, else falls back to bare 'sh'", () => { + Object.defineProperty(process, "platform", { value: "win32" }); + delete process.env.SHELL; + const shell = resolveShell(); + // Either a real Git-for-Windows sh.exe was found on this box, or we fall + // back to bare "sh" for PATH/WSL/Cygwin resolution — both are spawn-able, + // neither is the broken literal "/bin/sh". + expect(shell === "sh" || shell.toLowerCase().endsWith("sh.exe")).toBe(true); + }); + + it("on win32 honours an explicit SHELL env override when it exists", () => { + // process.env.SHELL inside a Git-Bash session points at a real sh.exe; if + // set and present, resolveShell prefers it. We can't guarantee a path + // exists in CI, so only assert the contract holds: result is spawn-able + // (bare 'sh' or a *.exe), never "/bin/sh". + Object.defineProperty(process, "platform", { value: "win32" }); + const shell = resolveShell(); + expect(shell).not.toBe("/bin/sh"); + expect(typeof shell).toBe("string"); + expect(shell.length).toBeGreaterThan(0); + }); +}); diff --git a/src/main/session-cache.ts b/src/main/session-cache.ts index 8e3f2f4bf..081abcb99 100644 --- a/src/main/session-cache.ts +++ b/src/main/session-cache.ts @@ -4,6 +4,8 @@ import { profileHome, getActiveProfileNameSync, activeStateDbPath, + listAllStateDbPaths, + stateDbPathForProfile, safeWriteFile, } from "./utils"; import Database from "better-sqlite3"; @@ -269,3 +271,150 @@ export function removeSessionFromCache(sessionId: string): void { writeCache(cache); } } + +// =========================================================================== +// Multi-profile cache sync. The single-profile sync above keys everything on +// the active profile. The aggregator below syncs the title cache for EACH +// profile DB so generated titles are computed once and persisted per profile, +// then returns nothing — the renderer reads the merged list from +// sessions.listAllSessions (which itself merges live DB rows + desktop meta). +// +// We still run the per-profile sync because it backfills `title` into the +// engine's state.db for sessions that never had one, which listAllSessions +// then surfaces. +// =========================================================================== + +function cacheFilePathForProfile(profile: string): string { + return join(profileHome(profile), "desktop", "sessions.json"); +} + +function readCacheForProfile(profile: string): CacheData { + const file = cacheFilePathForProfile(profile); + try { + if (!existsSync(file)) return { sessions: [], lastSync: 0 }; + return JSON.parse(readFileSync(file, "utf-8")); + } catch { + return { sessions: [], lastSync: 0 }; + } +} + +function writeCacheForProfile(profile: string, data: CacheData): void { + try { + safeWriteFile(cacheFilePathForProfile(profile), JSON.stringify(data)); + } catch { + // non-fatal + } +} + +/** + * Backfill generated titles into each profile's state.db + cache file. Run on + * the Sessions tab open so titles are stable across the aggregated view. + * Best-effort per profile; a failing DB is skipped. + */ +export function syncAllSessionCaches(): void { + for (const { profile, dbPath } of listAllStateDbPaths()) { + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const cache = readCacheForProfile(profile); + const existingIds = new Set(cache.sessions.map((s) => s.id)); + const rows = db + .prepare( + `SELECT s.id, s.started_at, s.source, s.message_count, s.model, s.title + FROM sessions s ORDER BY s.started_at DESC`, + ) + .all() as Array<{ + id: string; + started_at: number; + source: string; + message_count: number; + model: string; + title: string | null; + }>; + const merged: CachedSession[] = [...cache.sessions]; + for (const row of rows) { + if (existingIds.has(row.id)) continue; + let title = row.title || ""; + if (!title) { + try { + const msg = db + .prepare( + `SELECT content FROM messages + WHERE session_id = ? AND role = 'user' AND content IS NOT NULL + ORDER BY timestamp, id LIMIT 1`, + ) + .get(row.id) as { content: string } | undefined; + title = msg + ? generateTitle(msg.content) + : t("sessions.newConversation", getAppLocale()); + } catch { + title = t("sessions.newConversation", getAppLocale()); + } + } + merged.push({ + id: row.id, + title, + startedAt: row.started_at, + source: row.source, + messageCount: row.message_count, + model: row.model || "", + }); + } + merged.sort((a, b) => b.startedAt - a.startedAt); + writeCacheForProfile(profile, { + sessions: merged, + lastSync: Math.floor(Date.now() / 1000), + }); + } catch (err) { + console.error(`syncAllSessionCaches: skipping ${dbPath}`, err); + } finally { + db?.close(); + } + } +} + +/** + * Update a session title in a specific profile's cache + state.db (the + * profile-aware sibling of updateSessionTitle, which only ever touched the + * active profile). + */ +export function updateSessionTitleInProfile( + profile: string, + sessionId: string, + title: string, +): void { + const cache = readCacheForProfile(profile); + const idx = cache.sessions.findIndex((s) => s.id === sessionId); + if (idx >= 0) { + cache.sessions[idx].title = title; + writeCacheForProfile(profile, cache); + } + try { + const dbPath = stateDbPathForProfile(profile); + if (existsSync(dbPath)) { + const db = new Database(dbPath); + try { + db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run( + title, + sessionId, + ); + } finally { + db.close(); + } + } + } catch { + // ignore — cache update above is the fast path + } +} + +/** Remove a session from its profile's cache file (post-delete cleanup). */ +export function removeSessionFromProfileCache( + profile: string, + sessionId: string, +): void { + const cache = readCacheForProfile(profile); + const next = cache.sessions.filter((s) => s.id !== sessionId); + if (next.length !== cache.sessions.length) { + writeCacheForProfile(profile, { ...cache, sessions: next }); + } +} diff --git a/src/main/sessions.ts b/src/main/sessions.ts index 499dd6190..988c7b949 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -1,6 +1,10 @@ import Database from "better-sqlite3"; import { existsSync } from "fs"; -import { activeStateDbPath } from "./utils"; +import { + activeStateDbPath, + stateDbPathForProfile, + listAllStateDbPaths, +} from "./utils"; import type { Attachment } from "../shared/attachments"; import { isImageMime } from "../shared/attachments"; import { clearStagedAttachments } from "./attachment-staging"; @@ -168,6 +172,7 @@ export interface SearchResult { sessionId: string; title: string | null; startedAt: number; + lastActivity: number; source: string; messageCount: number; model: string; @@ -377,6 +382,11 @@ export function searchSessions(query: string, limit = 20): SearchResult[] { sessionId: r.session_id, title: r.title, startedAt: r.started_at, + // No per-session last-activity column is selected by these queries, so + // fall back to started_at — callers use lastActivity only for ordering/ + // display and the rows are already ordered by recency. Keeps the value a + // real number (satisfies SearchResult) without a schema/query change. + lastActivity: r.started_at, source: r.source, messageCount: r.message_count, model: r.model || "", @@ -694,3 +704,655 @@ export function deleteSessions(sessionIds: string[]): DeleteSessionsResult { return { requested: ids.length, deleted }; } + +// =========================================================================== +// Multi-profile aggregation + desktop-owned session metadata (issue: sessions +// disappeared because the desktop only read the active profile's state.db). +// +// Sessions live per-profile on disk. The desktop now lists across every +// profile DB and tags each row with its profile so resume/rename/delete can +// reopen the right DB. Extra UI state the engine doesn't model — pin, paused/ +// complete status, and groups — is stored in two desktop-owned tables created +// lazily in each profile's state.db. They never collide with engine writes. +// =========================================================================== + +export type SessionStatus = "active" | "paused" | "complete"; + +export interface SessionMeta { + pinned: boolean; + status: SessionStatus; + groupId: string | null; + pinnedAt: number | null; +} + +export interface SessionGroup { + id: string; + name: string; + color: string | null; + sortOrder: number; + createdAt: number; +} + +export interface AggregatedSession { + id: string; + profile: string; + title: string | null; + startedAt: number; + /** + * Timestamp of the most recent message in the session (epoch seconds), + * falling back to `startedAt` for sessions that have no messages yet. This + * — not `startedAt` — is what the UI sorts and date-groups by, so a fresh + * reply on an old conversation floats it back to the top. + */ + lastActivity: number; + source: string; + messageCount: number; + model: string; + archived: boolean; + pinned: boolean; + status: SessionStatus; + groupId: string | null; +} + +export interface AggregatedSearchResult extends AggregatedSession { + snippet: string; +} + +const DEFAULT_META: SessionMeta = { + pinned: false, + status: "active", + groupId: null, + pinnedAt: null, +}; + +/** + * Create the desktop-owned metadata tables if absent. Idempotent — safe to + * call on every DB open. These tables are additive and desktop-private; the + * engine never reads or writes them. + */ +export function ensureDesktopSessionTables(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS desktop_session_meta ( + session_id TEXT PRIMARY KEY, + pinned INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','paused','complete')), + group_id TEXT, + group_order INTEGER, + pinned_at REAL + ); + CREATE TABLE IF NOT EXISTS desktop_session_group ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL + ); + `); +} + +/** Open a writable DB for a profile, ensuring desktop tables exist. */ +function openProfileDb( + profile: string, + readonly: boolean, +): Database.Database | null { + const dbPath = stateDbPathForProfile(profile); + if (!existsSync(dbPath)) return null; + // Always open writable once to ensure the desktop tables exist, even for a + // "readonly" caller — a readonly handle can't CREATE TABLE. We open writable, + // ensure, and (for readonly callers) keep using it since better-sqlite3 + // gives no cheap downgrade; the cost is negligible and reads still work. + const db = new Database(dbPath, readonly ? {} : {}); + try { + ensureDesktopSessionTables(db); + } catch { + // If the schema can't be created (e.g. a corrupt DB) the meta-aware reads + // below fall back to defaults via try/catch, so don't fail the open. + } + return db; +} + +function rowToMeta(row: { + pinned: number | null; + status: string | null; + group_id: string | null; + pinned_at: number | null; +}): SessionMeta { + const status = + row.status === "paused" || row.status === "complete" + ? row.status + : "active"; + return { + pinned: !!row.pinned, + status, + groupId: row.group_id ?? null, + pinnedAt: row.pinned_at ?? null, + }; +} + +/** Read desktop meta for all sessions in one DB, keyed by session id. */ +function readAllMeta(db: Database.Database): Map { + const map = new Map(); + try { + const rows = db + .prepare( + `SELECT session_id, pinned, status, group_id, pinned_at + FROM desktop_session_meta`, + ) + .all() as Array<{ + session_id: string; + pinned: number | null; + status: string | null; + group_id: string | null; + pinned_at: number | null; + }>; + for (const r of rows) map.set(r.session_id, rowToMeta(r)); + } catch { + // table missing / unreadable — every session uses defaults + } + return map; +} + +/** Does this DB's sessions table carry an `archived` column? (older DBs may not) */ +function hasArchivedColumn(db: Database.Database): boolean { + try { + const cols = db.prepare(`PRAGMA table_info(sessions)`).all() as Array<{ + name: string; + }>; + return cols.some((c) => c.name === "archived"); + } catch { + return false; + } +} + +/** + * List sessions across every profile DB, tagged with profile + desktop meta. + * Best-effort: a DB that fails to open is skipped (the corrupted pre-update + * snapshot, for instance) rather than crashing the whole list. + */ +export function listAllSessions(limit = 200): AggregatedSession[] { + const all: AggregatedSession[] = []; + for (const { profile, dbPath } of listAllStateDbPaths()) { + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const archived = hasArchivedColumn(db); + const rows = db + .prepare( + `SELECT s.id, s.source, s.started_at, s.message_count, s.model, s.title + ${archived ? ", s.archived" : ""}, + (SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = s.id) + AS last_activity + FROM sessions s + ORDER BY COALESCE( + (SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = s.id), + s.started_at + ) DESC + LIMIT ?`, + ) + .all(limit) as Array<{ + id: string; + source: string; + started_at: number; + message_count: number; + model: string; + title: string | null; + archived?: number; + last_activity: number | null; + }>; + const meta = readAllMeta(db); + for (const r of rows) { + const m = meta.get(r.id) ?? DEFAULT_META; + all.push({ + id: r.id, + profile, + title: r.title, + startedAt: r.started_at, + lastActivity: r.last_activity ?? r.started_at, + source: r.source, + messageCount: r.message_count, + model: r.model || "", + archived: !!r.archived, + pinned: m.pinned, + status: m.status, + groupId: m.groupId, + }); + } + } catch (err) { + console.error(`listAllSessions: skipping ${dbPath}`, err); + } finally { + db?.close(); + } + } + all.sort((a, b) => b.lastActivity - a.lastActivity); + return all.slice(0, limit); +} + +/** Search across every profile DB; reuses the single-DB searchSessions logic. */ +export function searchAllSessions( + query: string, + limit = 20, +): AggregatedSearchResult[] { + const trimmed = query.trim(); + if (!trimmed) return []; + const all: AggregatedSearchResult[] = []; + for (const { profile, dbPath } of listAllStateDbPaths()) { + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const meta = readAllMeta(db); + const archived = hasArchivedColumn(db); + // Reuse the proven single-DB search by temporarily pointing at this DB. + const results = searchSessionsInDb(db, trimmed, limit, archived); + for (const r of results) { + const m = meta.get(r.sessionId) ?? DEFAULT_META; + all.push({ + id: r.sessionId, + profile, + title: r.title, + startedAt: r.startedAt, + lastActivity: r.lastActivity, + source: r.source, + messageCount: r.messageCount, + model: r.model, + archived: r.archived, + pinned: m.pinned, + status: m.status, + groupId: m.groupId, + snippet: r.snippet, + }); + } + } catch (err) { + console.error(`searchAllSessions: skipping ${dbPath}`, err); + } finally { + db?.close(); + } + } + // Title/FTS matches already ranked per-DB; across DBs sort by recency. + all.sort((a, b) => b.lastActivity - a.lastActivity); + return all.slice(0, limit); +} + +/** + * Single-DB search body shared by the aggregator. Mirrors `searchSessions` + * but takes an already-open DB so the aggregator opens each DB once. Returns + * an `archived` flag per row when the column exists. + */ +function searchSessionsInDb( + db: Database.Database, + trimmedQuery: string, + limit: number, + archived: boolean, +): Array { + const titleRows = db + .prepare( + `SELECT s.id as session_id, s.title, s.started_at, s.source, + s.message_count, s.model ${archived ? ", s.archived" : ""}, + (SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = s.id) + AS last_activity + FROM sessions s + WHERE LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\\' + OR LOWER(s.id) LIKE ? ESCAPE '\\' + ORDER BY s.started_at DESC + LIMIT ?`, + ) + .all( + `%${escapeLikePattern(trimmedQuery.toLocaleLowerCase())}%`, + `%${escapeLikePattern(trimmedQuery.toLocaleLowerCase())}%`, + limit, + ) as Array<{ + session_id: string; + title: string | null; + started_at: number; + source: string; + message_count: number; + model: string; + archived?: number; + last_activity: number | null; + }>; + + const titleMatches = titleRows.map((r) => ({ + ...r, + snippet: highlightSessionMatch(r.title, r.session_id, trimmedQuery), + })); + + const tableCheck = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'", + ) + .get() as { name: string } | undefined; + + const sanitized = trimmedQuery + .trim() + .split(/\s+/) + .filter((w) => w.length > 0) + .map((w) => `"${w.replace(/"/g, "")}"*`) + .join(" "); + + const ftsRows = tableCheck + ? (db + .prepare( + `SELECT DISTINCT m.session_id, s.title, s.started_at, s.source, + s.message_count, s.model ${archived ? ", s.archived" : ""}, + (SELECT MAX(m2.timestamp) FROM messages m2 + WHERE m2.session_id = s.id) AS last_activity, + snippet(messages_fts, 0, '<<', '>>', '...', 40) as snippet + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_id + WHERE messages_fts MATCH ? + ORDER BY rank + LIMIT ?`, + ) + .all(sanitized, Math.max(limit * 5, limit)) as Array<{ + session_id: string; + title: string | null; + started_at: number; + source: string; + message_count: number; + model: string; + archived?: number; + last_activity: number | null; + snippet: string; + }>) + : []; + + const uniqueRows = dedupeSearchRowsBySession( + [...titleMatches, ...ftsRows], + limit, + ); + return uniqueRows.map((r) => ({ + sessionId: r.session_id, + title: r.title, + startedAt: r.started_at, + lastActivity: r.last_activity ?? r.started_at, + source: r.source, + messageCount: r.message_count, + model: r.model || "", + snippet: r.snippet || "", + archived: !!r.archived, + })); +} + +/** Resume a session from a specific profile's DB (not the active-profile file). */ +export function getSessionMessagesFromProfile( + profile: string, + sessionId: string, +): HistoryItem[] { + const dbPath = stateDbPathForProfile(profile); + if (!existsSync(dbPath)) return []; + const db = new Database(dbPath, { readonly: true }); + try { + const rows = db + .prepare( + `SELECT id, role, content, timestamp, + tool_call_id, tool_calls, tool_name, + reasoning, reasoning_content, reasoning_details + FROM messages + WHERE session_id = ? AND role IN ('user', 'assistant', 'tool') + ORDER BY timestamp, id`, + ) + .all(sessionId) as RawMessageRow[]; + const items = expandRowsToHistory(rows); + return mergeStoredPromptImageAttachments( + items, + loadPromptImageAttachments(db, sessionId), + ); + } finally { + db.close(); + } +} + +/** Hard-delete a session from a specific profile's DB. */ +export function deleteSessionInProfile( + profile: string, + sessionId: string, +): void { + const id = normalizeSessionIds([sessionId])[0]; + if (!id) return; + const dbPath = stateDbPathForProfile(profile); + if (existsSync(dbPath)) { + const db = new Database(dbPath); + try { + const tx = db.transaction((sid: string) => { + deleteSessionRows(db, sid); + try { + db.prepare("DELETE FROM desktop_session_meta WHERE session_id = ?").run( + sid, + ); + } catch { + /* meta table may not exist */ + } + }); + tx(id); + } finally { + db.close(); + } + } + cleanupDeletedSession(id); +} + +/** Bulk-delete sessions grouped by profile. */ +export function deleteSessionsByProfile( + byProfile: Record, +): DeleteSessionsResult { + let requested = 0; + let deleted = 0; + for (const [profile, ids] of Object.entries(byProfile)) { + const norm = normalizeSessionIds(ids); + requested += norm.length; + const dbPath = stateDbPathForProfile(profile); + if (existsSync(dbPath)) { + const db = new Database(dbPath); + try { + const tx = db.transaction((list: string[]) => { + for (const id of list) { + deleted += deleteSessionRows(db, id); + try { + db.prepare( + "DELETE FROM desktop_session_meta WHERE session_id = ?", + ).run(id); + } catch { + /* meta table may not exist */ + } + } + }); + tx(norm); + } finally { + db.close(); + } + } + for (const id of norm) cleanupDeletedSession(id); + } + return { requested, deleted }; +} + +/** Rename a session's title in a specific profile's DB. */ +export function renameSessionInProfile( + profile: string, + sessionId: string, + title: string, +): void { + const dbPath = stateDbPathForProfile(profile); + if (!existsSync(dbPath)) return; + const db = new Database(dbPath); + try { + db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run( + title, + sessionId, + ); + } finally { + db.close(); + } +} + +/** Flip a session's engine-side `archived` flag in a specific profile's DB. */ +export function setSessionArchived( + profile: string, + sessionId: string, + archived: boolean, +): void { + const dbPath = stateDbPathForProfile(profile); + if (!existsSync(dbPath)) return; + const db = new Database(dbPath); + try { + if (!hasArchivedColumn(db)) return; // older DB without the column + db.prepare("UPDATE sessions SET archived = ? WHERE id = ?").run( + archived ? 1 : 0, + sessionId, + ); + } finally { + db.close(); + } +} + +function upsertMeta( + profile: string, + sessionId: string, + patch: Partial<{ + pinned: boolean; + status: SessionStatus; + groupId: string | null; + pinnedAt: number | null; + }>, +): void { + const db = openProfileDb(profile, false); + if (!db) return; + try { + // Insert a default row if absent, then patch the requested columns. + db.prepare( + `INSERT OR IGNORE INTO desktop_session_meta (session_id) VALUES (?)`, + ).run(sessionId); + const sets: string[] = []; + const vals: unknown[] = []; + if (patch.pinned !== undefined) { + sets.push("pinned = ?"); + vals.push(patch.pinned ? 1 : 0); + sets.push("pinned_at = ?"); + vals.push(patch.pinned ? Date.now() / 1000 : null); + } + if (patch.status !== undefined) { + sets.push("status = ?"); + vals.push(patch.status); + } + if (patch.groupId !== undefined) { + sets.push("group_id = ?"); + vals.push(patch.groupId); + } + if (sets.length === 0) return; + vals.push(sessionId); + db.prepare( + `UPDATE desktop_session_meta SET ${sets.join(", ")} WHERE session_id = ?`, + ).run(...vals); + } finally { + db.close(); + } +} + +export function setSessionPinned( + profile: string, + sessionId: string, + pinned: boolean, +): void { + upsertMeta(profile, sessionId, { pinned }); +} + +export function setSessionStatus( + profile: string, + sessionId: string, + status: SessionStatus, +): void { + upsertMeta(profile, sessionId, { status }); +} + +export function moveSessionToGroup( + profile: string, + sessionId: string, + groupId: string | null, +): void { + upsertMeta(profile, sessionId, { groupId }); +} + +export function listSessionGroups(profile: string): SessionGroup[] { + const db = openProfileDb(profile, true); + if (!db) return []; + try { + const rows = db + .prepare( + `SELECT id, name, color, sort_order, created_at + FROM desktop_session_group ORDER BY sort_order, created_at`, + ) + .all() as Array<{ + id: string; + name: string; + color: string | null; + sort_order: number; + created_at: number; + }>; + return rows.map((r) => ({ + id: r.id, + name: r.name, + color: r.color, + sortOrder: r.sort_order, + createdAt: r.created_at, + })); + } catch { + return []; + } finally { + db.close(); + } +} + +/** Aggregate groups across all profiles (UI shows one group list). */ +export function listAllSessionGroups(): Array { + const out: Array = []; + for (const { profile } of listAllStateDbPaths()) { + for (const g of listSessionGroups(profile)) out.push({ ...g, profile }); + } + return out; +} + +export function createSessionGroup( + profile: string, + name: string, + color?: string | null, +): SessionGroup | null { + const db = openProfileDb(profile, false); + if (!db) return null; + try { + const id = `grp-${Date.now().toString(36)}-${Math.floor( + Math.random() * 1e6, + ).toString(36)}`; + const createdAt = Date.now() / 1000; + const maxOrder = ( + db.prepare(`SELECT MAX(sort_order) as m FROM desktop_session_group`).get() as + | { m: number | null } + | undefined + )?.m; + const sortOrder = (maxOrder ?? 0) + 1; + db.prepare( + `INSERT INTO desktop_session_group (id, name, color, sort_order, created_at) + VALUES (?, ?, ?, ?, ?)`, + ).run(id, name, color ?? null, sortOrder, createdAt); + return { id, name, color: color ?? null, sortOrder, createdAt }; + } finally { + db.close(); + } +} + +export function deleteSessionGroup(profile: string, groupId: string): void { + const db = openProfileDb(profile, false); + if (!db) return; + try { + const tx = db.transaction((gid: string) => { + db.prepare("DELETE FROM desktop_session_group WHERE id = ?").run(gid); + // Un-group any sessions that pointed at it. + db.prepare( + "UPDATE desktop_session_meta SET group_id = NULL WHERE group_id = ?", + ).run(gid); + }); + tx(groupId); + } finally { + db.close(); + } +} diff --git a/src/main/utils.ts b/src/main/utils.ts index 0c803eb06..109960962 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -3,6 +3,7 @@ import { join, dirname, basename } from "path"; import { existsSync, mkdirSync, + readdirSync, readFileSync, renameSync, unlinkSync, @@ -198,6 +199,77 @@ export function activeStateDbPath(): string { return join(profileHome(getActiveProfileNameSync()), "state.db"); } +/** + * Resolve the session database for a *specific* profile by name. "default" + * (or empty) → ~/.hermes/state.db; named profiles → + * ~/.hermes/profiles//state.db. Used by the multi-profile Sessions + * aggregator for per-session operations (rename, archive, delete, resume) + * where the profile is known from the listing rather than the active-profile + * file (issue: sessions across profiles must each resolve to their own DB). + */ +export function stateDbPathForProfile(profile?: unknown): string { + return join(profileHome(profile), "state.db"); +} + +/** + * Enumerate every profile's session database that exists on disk: the root + * default profile (~/.hermes/state.db, reported as "default") plus each named + * profile under ~/.hermes/profiles//state.db. + * + * Only paths that actually exist are returned. This is the backbone of the + * Sessions aggregator: the desktop used to read just the active profile's DB, + * so when the active_profile file was missing it fell back to "default" and + * showed an empty list while real sessions sat in named-profile DBs. + */ +export function listAllStateDbPaths(): Array<{ + profile: string; + dbPath: string; +}> { + const out: Array<{ profile: string; dbPath: string }> = []; + + // Root / default profile. + const rootDb = join(HERMES_HOME, "state.db"); + if (existsSync(rootDb)) out.push({ profile: "default", dbPath: rootDb }); + + // Named profiles. + const profilesDir = join(HERMES_HOME, "profiles"); + if (existsSync(profilesDir)) { + let entries: string[] = []; + try { + entries = readdirSync(profilesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + entries = []; + } + for (const name of entries) { + if (!isValidNamedProfileName(name)) continue; + const dbPath = join(profilesDir, name, "state.db"); + if (existsSync(dbPath)) out.push({ profile: name, dbPath }); + } + } + + return out; +} + +/** + * Persist the active profile name to ~/.hermes/active_profile. The engine's + * `hermes profile use` writes this file; the desktop mirrors it so a profile + * selected purely in the UI survives a relaunch (and so `activeStateDbPath` + * resolves correctly). A missing file is exactly the regression that hid all + * sessions, so the desktop writes it defensively on profile switch. + */ +export function writeActiveProfileName(profile: string): void { + try { + const name = profile && profile !== "default" ? profile.trim() : "default"; + if (!existsSync(HERMES_HOME)) mkdirSync(HERMES_HOME, { recursive: true }); + writeFileSync(join(HERMES_HOME, "active_profile"), name, "utf-8"); + } catch { + // Best-effort — a write failure here only means the next launch may not + // restore this profile automatically; it is never fatal. + } +} + /** * Escape special regex characters in a string so it can be * safely interpolated into a RegExp constructor. diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7eb34fd9f..4e0699c24 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -13,6 +13,11 @@ import type { MessagingPlatformUpdate, } from "../shared/messaging-platforms"; import type { ChatToolEvent } from "../shared/chat-stream"; +import type { + CouncilConfig, + CouncilModelAdvice, + CouncilAdviceResult, +} from "../shared/council"; interface ElectronAPI { process: { @@ -100,6 +105,33 @@ interface CredentialPoolEntry { key?: string; } +/** One session as surfaced by the multi-profile aggregator. */ +interface AggregatedSession { + id: string; + profile: string; + title: string | null; + startedAt: number; + source: string; + messageCount: number; + model: string; + archived: boolean; + pinned: boolean; + status: "active" | "paused" | "complete"; + groupId: string | null; +} +interface AggregatedSearchResult extends AggregatedSession { + snippet: string; +} +/** A desktop-owned session group, tagged with the profile it lives in. */ +interface SessionGroupInfo { + id: string; + name: string; + color: string | null; + sortOrder: number; + createdAt: number; + profile: string; +} + interface KanbanTask { id: string; title: string; @@ -132,6 +164,76 @@ interface KanbanBoard { db_path?: string; } +// Factory governance status (mirror of `hermes kanban govern --json`). +interface GovernProfileState { + profile: string; + level: string | null; + protected_paths: string[]; + secret_scan: boolean; + hybrid: boolean; + model: string | null; + governed: boolean; +} +/** One active build in the orchestrator closed-loop (verify→correct→done). */ +interface GovernBuild { + root_id: string; + title: string | null; + task_status: string | null; + orchestrator: string | null; + loop_state: string | null; // building | verifying | correcting | done | parked + verify_round: number; + max_verify_rounds: number; + acceptance: string[]; + last_verdict: string | null; + last_summary: string | null; + unmet: Array>; + updated_at: string | null; +} +interface GovernStatus { + schema: number; + governance: { + valid_levels: string[]; + default_level: string; + level: string; + level_uniform: boolean; + secret_scan_patterns: number; + profiles: GovernProfileState[]; + }; + budget: { + kill_switch: { active: boolean; paths: string[]; present_at: string[] }; + dimensions: string[]; + default_max_iterations: number | null; + default_wallclock_seconds: number | null; + per_block_retry_cap: number | null; + }; + orchestration: Record; + builds?: GovernBuild[]; + activity: { + recent_governance_blocks: Array>; + recent_budget_events: Array>; + recent_builds: Array>; + change_log: Array>; + }; +} +interface GovernSetChange { + level?: "monitor" | "warn" | "gate" | "strict"; + secretScan?: "on" | "off"; + addProtected?: string; + removeProtected?: string; + profile?: string; + hybrid?: "on" | "off"; + model?: string; + orchestratorProfile?: string; + defaultAssignee?: string; + autoDecompose?: "on" | "off"; + autoDecomposePerTick?: number; + maxInProgress?: number; + orchestratorLoop?: "on" | "off"; + maxVerifyRounds?: number; + defaultMaxIterations?: number; + defaultWallclock?: number; +} + interface KanbanComment { id: number; task_id: string; @@ -219,6 +321,13 @@ interface HermesAPI { cancelOAuthLogin: () => Promise; onOAuthLoginProgress: (callback: (chunk: string) => void) => () => void; + // Anthropic Claude (OAuth) — native paste-a-code PKCE flow + anthropicOauthStart: () => Promise<{ url: string }>; + anthropicOauthSubmit: ( + code: string, + profile?: string, + ) => Promise<{ success: boolean; error?: string }>; + getLocale: () => Promise; setLocale: (locale: AppLocale) => Promise; @@ -264,6 +373,46 @@ interface HermesAPI { profile?: string, ) => Promise; resetAuxiliaryConfig: (profile?: string) => Promise; + + // LLM Council roster + advisor + councilGetConfig: (profile?: string) => Promise; + councilResetConfig: (profile?: string) => Promise; + councilAddMember: ( + member: { model: string; label?: string; free?: boolean; positionId?: string | null }, + profile?: string, + ) => Promise; + councilRemoveMember: (memberId: string, profile?: string) => Promise; + councilAssignPosition: ( + memberId: string, + positionId: string | null, + profile?: string, + ) => Promise; + councilSetChairman: (model: string, profile?: string) => Promise; + councilUpsertPosition: ( + pos: { id?: string; title: string; description: string }, + profile?: string, + ) => Promise; + councilDeletePosition: (positionId: string, profile?: string) => Promise; + councilPositionFeedback: ( + positionId: string, + vote: "up" | "down", + profile?: string, + ) => Promise; + councilProposeDescription: ( + positionId: string, + proposed: string, + profile?: string, + ) => Promise; + councilResolveDescription: ( + positionId: string, + accept: boolean, + profile?: string, + ) => Promise; + councilRecommendModels: ( + taskKind: string, + preferFree?: boolean, + ) => Promise; + councilModelAdvice: () => Promise; setModelConfig: ( provider: string, model: string, @@ -647,6 +796,65 @@ interface HermesAPI { }> >; + // Multi-profile session aggregation + desktop session metadata. + listAllSessions: (limit?: number) => Promise; + syncAllSessionCaches: () => Promise; + searchAllSessions: ( + query: string, + limit?: number, + ) => Promise; + getSessionMessagesFromProfile: ( + profile: string, + sessionId: string, + ) => Promise< + Array<{ + id: number; + role: "user" | "assistant"; + content: string; + timestamp: number; + attachments?: Attachment[]; + }> + >; + deleteSessionInProfile: ( + profile: string, + sessionId: string, + ) => Promise; + deleteSessionsByProfile: ( + byProfile: Record, + ) => Promise<{ requested: number; deleted: number }>; + renameSession: ( + profile: string, + sessionId: string, + title: string, + ) => Promise; + setSessionArchived: ( + profile: string, + sessionId: string, + archived: boolean, + ) => Promise; + setSessionPinned: ( + profile: string, + sessionId: string, + pinned: boolean, + ) => Promise; + setSessionStatus: ( + profile: string, + sessionId: string, + status: "active" | "paused" | "complete", + ) => Promise; + moveSessionToGroup: ( + profile: string, + sessionId: string, + groupId: string | null, + ) => Promise; + listSessionGroups: () => Promise; + createSessionGroup: ( + profile: string, + name: string, + color?: string | null, + ) => Promise; + deleteSessionGroup: (profile: string, groupId: string) => Promise; + // Credential Pool (profile-aware) — entries follow the upstream // engine schema (issue #367). See `CredentialPoolEntry` below. getCredentialPool: ( @@ -804,6 +1012,31 @@ interface HermesAPI { error?: string; unsupportedMode?: boolean; }>; + // Factory governance + kanbanGovernStatus: () => Promise<{ + success: boolean; + data?: GovernStatus; + error?: string; + unsupportedMode?: boolean; + }>; + kanbanGovernSet: (change: GovernSetChange) => Promise<{ + success: boolean; + data?: unknown; + error?: string; + unsupportedMode?: boolean; + }>; + kanbanGovernKillSwitch: (on: boolean) => Promise<{ + success: boolean; + data?: unknown; + error?: string; + unsupportedMode?: boolean; + }>; + kanbanGovernModels: () => Promise<{ + success: boolean; + data?: { models: string[]; source: string; base_url: string | null }; + error?: string; + unsupportedMode?: boolean; + }>; kanbanCurrentBoard: ( profile?: string, ) => Promise<{ success: boolean; data?: string; error?: string }>; diff --git a/src/preload/index.ts b/src/preload/index.ts index 66b4008e5..3003d0abf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,11 @@ import type { MessagingPlatformUpdate, } from "../shared/messaging-platforms"; import type { ChatToolEvent } from "../shared/chat-stream"; +import type { + CouncilConfig, + CouncilAdviceResult, + CouncilModelAdvice, +} from "../shared/council"; /** * Mirror of the renderer-side `CredentialPoolEntry` ambient type @@ -35,6 +40,37 @@ interface GatewayStartResult { logPath?: string; } +/** + * Aggregated session shapes — mirror of sessions.ts AggregatedSession / + * AggregatedSearchResult / SessionGroup. Preload is type-checked under + * tsconfig.node.json which doesn't pull in the main-process types, so the + * shapes are restated here (same pattern as CredentialPoolEntry). + */ +interface AggregatedSession { + id: string; + profile: string; + title: string | null; + startedAt: number; + source: string; + messageCount: number; + model: string; + archived: boolean; + pinned: boolean; + status: "active" | "paused" | "complete"; + groupId: string | null; +} +interface AggregatedSearchResult extends AggregatedSession { + snippet: string; +} +interface SessionGroupInfo { + id: string; + name: string; + color: string | null; + sortOrder: number; + createdAt: number; + profile: string; +} + const electronAPI = { process: { platform: process.platform, @@ -131,6 +167,15 @@ const hermesAPI = { return () => ipcRenderer.removeListener("oauth-login-progress", handler); }, + // Anthropic Claude (OAuth) — native paste-a-code PKCE flow + anthropicOauthStart: (): Promise<{ url: string }> => + ipcRenderer.invoke("anthropic-oauth-start"), + anthropicOauthSubmit: ( + code: string, + profile?: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("anthropic-oauth-submit", code, profile), + getLocale: (): Promise => ipcRenderer.invoke("get-locale"), setLocale: (locale: AppLocale): Promise => ipcRenderer.invoke("set-locale", locale), @@ -209,6 +254,59 @@ const hermesAPI = { resetAuxiliaryConfig: (profile?: string): Promise => ipcRenderer.invoke("reset-auxiliary-config", profile), + // LLM Council roster (desktop-side; consumed by the agent via PAL MCP). + councilGetConfig: (profile?: string): Promise => + ipcRenderer.invoke("council-get-config", profile), + councilResetConfig: (profile?: string): Promise => + ipcRenderer.invoke("council-reset-config", profile), + councilAddMember: ( + member: { model: string; label?: string; free?: boolean; positionId?: string | null }, + profile?: string, + ): Promise => + ipcRenderer.invoke("council-add-member", member, profile), + councilRemoveMember: (memberId: string, profile?: string): Promise => + ipcRenderer.invoke("council-remove-member", memberId, profile), + councilAssignPosition: ( + memberId: string, + positionId: string | null, + profile?: string, + ): Promise => + ipcRenderer.invoke("council-assign-position", memberId, positionId, profile), + councilSetChairman: (model: string, profile?: string): Promise => + ipcRenderer.invoke("council-set-chairman", model, profile), + councilUpsertPosition: ( + pos: { id?: string; title: string; description: string }, + profile?: string, + ): Promise => + ipcRenderer.invoke("council-upsert-position", pos, profile), + councilDeletePosition: (positionId: string, profile?: string): Promise => + ipcRenderer.invoke("council-delete-position", positionId, profile), + councilPositionFeedback: ( + positionId: string, + vote: "up" | "down", + profile?: string, + ): Promise => + ipcRenderer.invoke("council-position-feedback", positionId, vote, profile), + councilProposeDescription: ( + positionId: string, + proposed: string, + profile?: string, + ): Promise => + ipcRenderer.invoke("council-propose-description", positionId, proposed, profile), + councilResolveDescription: ( + positionId: string, + accept: boolean, + profile?: string, + ): Promise => + ipcRenderer.invoke("council-resolve-description", positionId, accept, profile), + councilRecommendModels: ( + taskKind: string, + preferFree?: boolean, + ): Promise => + ipcRenderer.invoke("council-recommend-models", taskKind, preferFree), + councilModelAdvice: (): Promise => + ipcRenderer.invoke("council-model-advice"), + // Connection mode (local / remote / ssh) isRemoteMode: (): Promise => ipcRenderer.invoke("is-remote-mode"), isRemoteOnlyMode: (): Promise => @@ -778,6 +876,83 @@ const hermesAPI = { }> > => ipcRenderer.invoke("search-sessions", query, limit), + // Multi-profile session aggregation + desktop session metadata. + listAllSessions: (limit?: number): Promise => + ipcRenderer.invoke("list-all-sessions", limit), + syncAllSessionCaches: (): Promise => + ipcRenderer.invoke("sync-all-session-caches"), + searchAllSessions: ( + query: string, + limit?: number, + ): Promise => + ipcRenderer.invoke("search-all-sessions", query, limit), + getSessionMessagesFromProfile: ( + profile: string, + sessionId: string, + ): Promise< + Array<{ + id: number; + role: "user" | "assistant"; + content: string; + timestamp: number; + attachments?: Attachment[]; + }> + > => + ipcRenderer.invoke( + "get-session-messages-from-profile", + profile, + sessionId, + ), + deleteSessionInProfile: ( + profile: string, + sessionId: string, + ): Promise => + ipcRenderer.invoke("delete-session-in-profile", profile, sessionId), + deleteSessionsByProfile: ( + byProfile: Record, + ): Promise<{ requested: number; deleted: number }> => + ipcRenderer.invoke("delete-sessions-by-profile", byProfile), + renameSession: ( + profile: string, + sessionId: string, + title: string, + ): Promise => + ipcRenderer.invoke("rename-session", profile, sessionId, title), + setSessionArchived: ( + profile: string, + sessionId: string, + archived: boolean, + ): Promise => + ipcRenderer.invoke("set-session-archived", profile, sessionId, archived), + setSessionPinned: ( + profile: string, + sessionId: string, + pinned: boolean, + ): Promise => + ipcRenderer.invoke("set-session-pinned", profile, sessionId, pinned), + setSessionStatus: ( + profile: string, + sessionId: string, + status: "active" | "paused" | "complete", + ): Promise => + ipcRenderer.invoke("set-session-status", profile, sessionId, status), + moveSessionToGroup: ( + profile: string, + sessionId: string, + groupId: string | null, + ): Promise => + ipcRenderer.invoke("move-session-to-group", profile, sessionId, groupId), + listSessionGroups: (): Promise => + ipcRenderer.invoke("list-session-groups"), + createSessionGroup: ( + profile: string, + name: string, + color?: string | null, + ): Promise => + ipcRenderer.invoke("create-session-group", profile, name, color), + deleteSessionGroup: (profile: string, groupId: string): Promise => + ipcRenderer.invoke("delete-session-group", profile, groupId), + // Credential Pool (profile-aware: reads/writes the named profile's // auth.json; defaults to the currently active profile when omitted) // @@ -1031,6 +1206,13 @@ const hermesAPI = { // Kanban kanbanListBoards: (includeArchived?: boolean, profile?: string) => ipcRenderer.invoke("kanban-list-boards", includeArchived, profile), + // Factory governance + kanbanGovernStatus: () => ipcRenderer.invoke("kanban-govern-status"), + kanbanGovernSet: (change: unknown) => + ipcRenderer.invoke("kanban-govern-set", change), + kanbanGovernKillSwitch: (on: boolean) => + ipcRenderer.invoke("kanban-govern-killswitch", on), + kanbanGovernModels: () => ipcRenderer.invoke("kanban-govern-models"), kanbanCurrentBoard: (profile?: string) => ipcRenderer.invoke("kanban-current-board", profile), kanbanSwitchBoard: (slug: string, profile?: string) => diff --git a/src/renderer/index.html b/src/renderer/index.html index 926921ea2..4abbdb7e3 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ Hermes One diff --git a/src/renderer/src/assets/icons/index.tsx b/src/renderer/src/assets/icons/index.tsx index 4ef853503..aee015d4b 100644 --- a/src/renderer/src/assets/icons/index.tsx +++ b/src/renderer/src/assets/icons/index.tsx @@ -25,6 +25,7 @@ export { Search, Send, Settings, + ShieldCheck, Signal, Sparkles, Sun, @@ -52,3 +53,12 @@ export { Filter as TriageIcon } from "lucide-react"; export { Shield as ApprovalIcon } from "lucide-react"; export { Folder as CuratorIcon } from "lucide-react"; export { User as ProfileIcon } from "lucide-react"; +// LLM Council +export { + Landmark as CouncilIcon, + Scale as AdvisorIcon, + ThumbsUp, + ThumbsDown, + Crown, + Gauge, +} from "lucide-react"; diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9af1db136..38f1223f7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -5,6 +5,17 @@ Dark + Light mode, no gradients, flat design ======================================================================== */ +/* ----- Theme-agnostic fallback tokens ----- + These were referenced across the app (modals, popovers, toasts, danger + actions, inline-edit surfaces) but never defined, so the consumers rendered + flat / transparent. Defined once at :root so every theme inherits them; a + theme can override any of these in its own block below. */ +:root { + --shadow-md: 0 4px 12px -2px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 30px -8px rgba(0, 0, 0, 0.55); + --danger: #ef4444; +} + /* ----- Dark Theme (default) ----- */ [data-theme="dark"] { --bg-primary: #212121; @@ -1478,6 +1489,19 @@ body { } } +/* Row wrapper: holds the open-button + the ⋮ menu side by side. The menu + button is revealed on hover/focus-within (ChatGPT-style). */ +.sidebar-recent-session-row { + position: relative; + display: flex; + align-items: center; + border-radius: var(--radius-md); +} + +.sidebar-recent-session-row.active { + background: var(--accent-subtle); +} + .sidebar-recent-session { display: flex; align-items: center; @@ -1494,6 +1518,127 @@ body { transition: all var(--transition); } +/* The open-button fills the row so the title is fully clickable; the menu + button is laid out after it. */ +.sidebar-recent-session-open { + flex: 1; + min-width: 0; +} + +/* Pin star marker pushed to the end of the title row. */ +.sidebar-recent-session-pin { + margin-left: auto; + padding-left: 6px; + font-size: 10px; + color: var(--accent-text, var(--accent)); + flex-shrink: 0; +} + +/* ⋮ menu container: reserve no layout space until revealed so titles keep + their full width. */ +.sidebar-recent-session-menu { + flex-shrink: 0; + margin-left: 0; +} + +/* The popover is portalled to document.body (to escape the sidebar's + overflow:hidden clip), positioned via inline fixed top/left. Override the + base absolute positioning and float above all app chrome. */ +.sidebar-recent-session-popover { + position: fixed; + top: auto; + right: auto; + z-index: 1000; + /* Tighter than the Sessions-screen card menu — a compact sidebar popover. */ + min-width: 184px; + padding: 3px; + gap: 0; +} +.sidebar-recent-session-popover button[role="menuitem"] { + padding: 5px 9px; + font-size: 12.5px; + gap: 7px; +} + +.sidebar-recent-session-menu .sessions-card-menu-btn { + width: 24px; + height: 24px; + font-size: 16px; + opacity: 0; +} + +/* Reveal the ⋮ on row hover, on keyboard focus within the row, or while its + own menu is open (aria-expanded). Always visible on the active row. */ +.sidebar-recent-session-row:hover .sessions-card-menu-btn, +.sidebar-recent-session-row:focus-within .sessions-card-menu-btn, +.sidebar-recent-session-row.active .sessions-card-menu-btn, +.sidebar-recent-session-menu .sessions-card-menu-btn[aria-expanded="true"] { + opacity: 0.8; +} +.sidebar-recent-session-menu .sessions-card-menu-btn:hover, +.sidebar-recent-session-menu .sessions-card-menu-btn:focus-visible { + opacity: 1; +} + +/* Inline rename input — sized to match the row. */ +.sidebar-recent-session-rename { + flex: 1; + min-width: 0; + margin: 2px 6px; + padding: 5px 8px; + font-family: var(--font-sans); + font-size: 12px; + color: var(--text-primary); + background: var(--bg-primary); + border: 1px solid var(--border-focus); + border-radius: var(--radius-sm); + outline: none; +} + +/* Delete confirmation popover anchored under the row. */ +.sidebar-recent-session-delete-confirm { + position: absolute; + top: calc(100% + 4px); + right: 0; + left: 0; + z-index: 90; + padding: 10px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); +} +.sidebar-recent-session-delete-confirm p { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} +.sidebar-recent-session-delete-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} +.sidebar-recent-session-delete-actions button { + padding: 5px 10px; + font-size: 12px; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: var(--radius-sm); + cursor: pointer; +} +.sidebar-recent-session-delete-actions button:hover { + background: var(--bg-tertiary); +} +.sidebar-recent-session-delete-actions button.sessions-menu-danger { + border-color: var(--danger, #e06c75); +} +.sidebar-recent-session-delete-actions button:disabled { + opacity: 0.6; + cursor: default; +} + .sidebar-recent-session-dot { flex-shrink: 0; color: var(--text-muted); @@ -3494,6 +3639,43 @@ body { background: var(--bg-active); } +/* LLM Council convene button — distinct accent-tinted affordance, sits at the + right of the toolbar just before the send group. */ +.chat-council-btn { + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--accent-subtle, var(--bg-hover)); + border: 1px solid var(--accent-subtle, transparent); + color: var(--accent); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-right: 2px; + transition: + background var(--transition), + color var(--transition), + border-color var(--transition); +} + +.chat-council-btn:hover:not(:disabled) { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.chat-council-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Gauge now lives at the far right of the toolbar (after the send group). */ +.chat-input-toolbar .chat-ctx-gauge { + margin-left: 6px; +} + /* ── Attachments ────────────────────────────────────── */ .chat-attach-btn { @@ -6955,68 +7137,6 @@ body { flex-wrap: wrap; } -/* Delete (trash) button on each session card — #408. - * Slides into the right side of the tags row so it doesn't fight the - * card's main click target. Stays subtle until hover so the row doesn't - * look cluttered when the user is just browsing. */ -.sessions-card-delete { - margin-left: auto; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 4px; - border: none; - background: transparent; - color: var(--text-muted); - border-radius: var(--radius-sm); - cursor: pointer; - opacity: 0.45; - transition: - opacity var(--transition), - background var(--transition), - color var(--transition); -} - -.sessions-card:hover .sessions-card-delete { - opacity: 1; -} - -.sessions-card-delete:hover, -.sessions-card-delete:focus-visible { - background: var(--bg-tertiary); - color: var(--danger, #e06c75); - opacity: 1; -} - -/* Rename button — sits next to delete in the tags row */ -.sessions-card-rename { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 4px; - border: none; - background: transparent; - color: var(--text-muted); - border-radius: var(--radius-sm); - cursor: pointer; - opacity: 0.45; - transition: - opacity var(--transition), - background var(--transition), - color var(--transition); -} - -.sessions-card:hover .sessions-card-rename { - opacity: 1; -} - -.sessions-card-rename:hover, -.sessions-card-rename:focus-visible { - background: var(--bg-tertiary); - color: var(--text-primary); - opacity: 1; -} - /* Inline rename input inside the card */ .sessions-card-rename-input { flex: 1; @@ -7024,7 +7144,7 @@ body { font-size: 14px; font-weight: 500; color: var(--text-primary); - background: var(--bg-base); + background: var(--bg-primary); border: 1px solid var(--border-focus); border-radius: var(--radius-sm); padding: 3px 8px; @@ -7137,92 +7257,336 @@ body { border-radius: 2px; } -/* ======================================================================== - SKILLS - ======================================================================== */ -.skills-container { - display: flex; - flex-direction: column; - height: 100%; - overflow-y: auto; - padding: 24px 32px; -} - -.skills-loading { - flex: 1; - display: flex; - align-items: center; - justify-content: center; +/* Multi-profile aggregator UI: profile chip + status dot + per-card overflow + menu + pinned section + group filter + toast. */ +/* Filled pill: the profile palette (profileColors.ts) is designed for white + text on the saturated colour as a fill. Rendering the colour as foreground + text on the dark card failed WCAG 1.4.3 for several hues (slate/purple/ + gray). The fill + white text clears 4.5:1 for every palette entry and makes + the profile the visual anchor of this multi-profile tab. (bg/color set + inline per-profile in ProfileChip.) */ +.sessions-tag--profile { + font-weight: 600; + border: none; } -.skills-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 16px; +.sessions-card--pinned { + border-left: 2px solid var(--accent, #61afef); } -/* Embedded (Capabilities → Skills): search + refresh share one row. */ -.skills-search-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; +/* Keyboard focus indicator — these were invisible on dark themes (only the + faint UA ring). WCAG 2.4.7. */ +.sessions-card:focus-visible, +.sessions-group-filter:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; } -.skills-search-row .skills-search { - flex: 1; - margin-bottom: 0; +/* Search result count line above grouped results. */ +.sessions-result-count { + font-size: 12px; + color: var(--text-muted); + padding: 4px 4px 8px; } -.skills-search-refresh { - flex-shrink: 0; +.sessions-archived-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + user-select: none; } -.skills-title { - font-size: 20px; - font-weight: 700; +.sessions-group-filter { + font-size: 12px; + padding: 4px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); color: var(--text-primary); } -.skills-subtitle { - font-size: 13px; - color: var(--text-muted); - margin-top: 2px; -} - -.skills-error { +.sessions-search-row { display: flex; - align-items: center; - justify-content: space-between; - background: var(--error-bg); - border: 1px solid var(--error); - border-radius: var(--radius-sm); - padding: 8px 12px; - margin-bottom: 12px; - color: var(--error); - font-size: 13px; + flex-direction: column; + gap: 4px; } -/* Tabs */ -.skills-tabs { - display: flex; - gap: 2px; - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - padding: 2px; - margin-bottom: 12px; +.sessions-menu { + position: relative; + margin-left: auto; } -.skills-tab { - flex: 1; - padding: 8px 16px; +.sessions-card-menu-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; border: none; background: transparent; - border-radius: 4px; + color: var(--text-muted); + border-radius: var(--radius-sm); cursor: pointer; - font-family: var(--font-sans); - font-size: 13px; + font-size: 18px; + line-height: 1; + opacity: 0.7; + transition: + opacity var(--transition), + background var(--transition), + color var(--transition); +} + +.sessions-card:hover .sessions-card-menu-btn, +.sessions-card-menu-btn:focus-visible { + opacity: 1; +} + +.sessions-card-menu-btn:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +.sessions-card-menu-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sessions-menu-popover { + position: absolute; + right: 0; + top: calc(100% + 4px); + min-width: 200px; + padding: 4px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 80; + display: flex; + flex-direction: column; + gap: 1px; +} + +.sessions-menu-popover button[role="menuitem"] { + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 10px; + background: transparent; + border: none; + color: var(--text-primary); + font-size: 13px; + text-align: left; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.sessions-menu-popover button[role="menuitem"]:hover, +.sessions-menu-popover button[role="menuitem"]:focus-visible { + background: var(--bg-tertiary); + outline: none; +} + +.sessions-menu-danger { + color: var(--danger, #e06c75) !important; +} + +/* Active (DOM-focused) menu item — visible focus inside the popover. */ +.sessions-menu-popover button[role="menuitem"]:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: -2px; +} + +/* Reset the
    wrapping the cards — no bullets, just a block. */ +.sessions-card-list { + list-style: none; + padding: 0; + margin: 0; +} + +/* The card's title is now a real
    - {status === "running" && ( -

    - {t("providers.oauth.runningHint")} -

    - )} {status === "success" && (
    ✓ {t("providers.oauth.successHint")} @@ -104,14 +164,57 @@ function OAuthLoginModal({ ✗ {error}
    )} - {log && ( -
    -              {log}
    -            
    + + {isAnthropic ? ( + status === "running" && ( +
    +

    + {t("providers.oauth.runningHint")} +

    + {authUrl && ( +

    + + {authUrl} + +

    + )} + setCode(e.target.value)} + placeholder="code#state" + autoComplete="off" + spellCheck={false} + /> +
    + ) + ) : ( + <> + {status === "running" && ( +

    + {t("providers.oauth.runningHint")} +

    + )} + {log && ( +
    +                  {log}
    +                
    + )} + )}
    - + )} +
    diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index af82fe61a..e40239d11 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -259,6 +259,11 @@ export const OAUTH_PROVIDERS: OAuthProviderDef[] = [ name: "ChatGPT (Codex Plan)", desc: "providers.oauth.codexDesc", }, + { + id: "anthropic", + name: "Anthropic Claude (OAuth)", + desc: "providers.oauth.anthropicDesc", + }, { id: "xai-oauth", name: "xAI Grok (OAuth)", diff --git a/src/renderer/src/screens/Agents/AgentDetail.test.tsx b/src/renderer/src/screens/Agents/AgentDetail.test.tsx new file mode 100644 index 000000000..e1c75d910 --- /dev/null +++ b/src/renderer/src/screens/Agents/AgentDetail.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import AgentDetail from "./AgentDetail"; + +// i18n returns the key so we assert wiring without locale lookups. +vi.mock("../../components/useI18n", () => ({ + useI18n: () => ({ t: (k: string) => k, locale: "en", setLocale: () => {} }), +})); + +// Stub the three composed screens so this test targets AgentDetail's own +// behaviour (tab switching, profile threading, close) — each child has its +// own tests. +vi.mock("../Soul/Soul", () => ({ + default: ({ profile }: { profile: string }) => ( +
    persona:{profile}
    + ), +})); +vi.mock("../Skills/Skills", () => ({ + default: ({ profile }: { profile: string }) => ( +
    skills:{profile}
    + ), +})); +vi.mock("../Tools/Tools", () => ({ + default: ({ profile }: { profile: string }) => ( +
    tools:{profile}
    + ), +})); +vi.mock("../../components/common/ProfileAvatar", () => ({ + default: () =>
    , +})); + +beforeEach(() => { + // @ts-expect-error test shim + global.window.hermesAPI = {}; +}); + +describe("AgentDetail (per-agent persona/skills/tools)", () => { + it("defaults to the persona tab, threading the profile to Soul", () => { + render( {}} />); + expect(screen.getByTestId("soul")).toHaveTextContent("persona:architect"); + expect(screen.queryByTestId("skills")).toBeNull(); + expect(screen.queryByTestId("tools")).toBeNull(); + }); + + it("switches to Skills and Tools, always threading the SAME profile", () => { + render( {}} />); + fireEvent.click(screen.getByText("skills.title")); + expect(screen.getByTestId("skills")).toHaveTextContent( + "skills:data-analyst", + ); + fireEvent.click(screen.getByText("tools.title")); + expect(screen.getByTestId("tools")).toHaveTextContent( + "tools:data-analyst", + ); + }); + + it("honours initialTab so Office clickthrough can deep-link a tab", () => { + render( + {}} + />, + ); + expect(screen.getByTestId("tools")).toHaveTextContent( + "tools:code-reviewer", + ); + }); + + it("calls onClose from the close button", async () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("common.cancel")); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("closes on overlay backdrop click but NOT on inner modal click", () => { + const onClose = vi.fn(); + const { container } = render( + , + ); + // Inner dialog click — must NOT close. + fireEvent.click(screen.getByRole("dialog")); + expect(onClose).not.toHaveBeenCalled(); + // Backdrop overlay click — must close. + const overlay = container.querySelector(".agent-detail-overlay"); + if (overlay) fireEvent.click(overlay); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/src/screens/Agents/AgentDetail.tsx b/src/renderer/src/screens/Agents/AgentDetail.tsx new file mode 100644 index 000000000..b164ce96f --- /dev/null +++ b/src/renderer/src/screens/Agents/AgentDetail.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { Sparkles, Puzzle, Wrench, X } from "../../assets/icons"; +import ProfileAvatar from "../../components/common/ProfileAvatar"; +import { useI18n } from "../../components/useI18n"; +import Soul from "../Soul/Soul"; +import Skills from "../Skills/Skills"; +import Tools from "../Tools/Tools"; + +export type AgentDetailTab = "persona" | "skills" | "tools"; + +interface AgentDetailProps { + /** Profile/agent name whose persona, skills and tools are being edited. */ + profile: string; + /** Display colour + avatar for the header (optional). */ + color?: string | null; + avatar?: string | null; + /** Tab to open on first mount. Defaults to "persona". */ + initialTab?: AgentDetailTab; + /** Browse-skills affordance — jumps to the Discover → Skills tab. */ + onBrowseSkills?: () => void; + onClose: () => void; +} + +/** + * Per-agent configuration panel. Composes the existing, profile-aware Soul + * (persona), Skills and Tools screens into one tabbed overlay so a single + * agent's persona, assigned skills and enabled toolsets all live in one + * place — reachable from both the Agents list and the Office 3D bots. + * + * Every child already accepts a `profile` prop and reads/writes that + * profile's own files (SOUL.md, skills/, config toolsets), so this is a + * pure composition — no new backend wiring. + */ +function AgentDetail({ + profile, + color, + avatar, + initialTab = "persona", + onBrowseSkills, + onClose, +}: AgentDetailProps): React.JSX.Element { + const { t } = useI18n(); + const [tab, setTab] = useState(initialTab); + + const tabs: { id: AgentDetailTab; label: string; icon: typeof Sparkles }[] = [ + { id: "persona", label: t("soul.title"), icon: Sparkles }, + { id: "skills", label: t("skills.title"), icon: Puzzle }, + { id: "tools", label: t("tools.title"), icon: Wrench }, + ]; + + return ( +
    { + if (e.target === e.currentTarget) onClose(); + }} + > +
    e.stopPropagation()} + > +
    +
    + +
    +
    {profile}
    +
    {t("agents.configure")}
    +
    +
    + +
    + +
    + {tabs.map(({ id, label, icon: Icon }) => ( + + ))} +
    + +
    + {tab === "persona" && } + {tab === "skills" && ( + + )} + {tab === "tools" && ( + + )} +
    +
    +
    + ); +} + +export default AgentDetail; diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index 97c06324e..222ac47c8 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -1,12 +1,16 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Zap } from "lucide-react"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; +import { buildCouncilPrompt } from "./councilPrompt"; import { ChatEmptyState } from "./ChatEmptyState"; import { MessageList } from "./MessageList"; import { ModelPicker } from "./ModelPicker"; import { ReasoningEffortPicker } from "./ReasoningEffortPicker"; import { ContextFolderChip } from "./ContextFolderChip"; import { WorktreePanel } from "./WorktreePanel"; +import { FactoryToggle } from "./FactoryToggle"; +import { FactoryPanel } from "./FactoryPanel"; +import { useFactoryStatus } from "../shared/useFactoryStatus"; import { useChatScroll } from "./hooks/useChatScroll"; import { useChatIPC } from "./hooks/useChatIPC"; import { useChatActions } from "./hooks/useChatActions"; @@ -54,6 +58,8 @@ interface ChatProps { onSessionIdChange?: (runId: string, sessionId: string | null) => void; /** Reports the first user message as a best-effort conversation title. */ onTitleChange?: (runId: string, title: string) => void; + /** Focus a factory build's task in the Kanban tab (in-chat Factory panel). */ + onNavigateToTask?: (taskId: string) => void; } function Chat({ @@ -68,6 +74,7 @@ function Chat({ onLoadingChange, onSessionIdChange, onTitleChange, + onNavigateToTask, }: ChatProps): React.JSX.Element { const { t } = useI18n(); const [messages, setMessages] = useState( @@ -106,6 +113,10 @@ function Chat({ // Whether the worktree panel is visible (only applies when contextFolder is set) // Default false so the panel doesn't open automatically and interfere with scrolling const [worktreeVisible, setWorktreeVisible] = useState(false); + // In-chat Factory panel. Closed by default; opening it also enables the + // orchestrator closed-loop ("factory mode"). Hidden in remote mode. + const [factoryVisible, setFactoryVisible] = useState(false); + const factory = useFactoryStatus(factoryVisible && !remoteMode); const dragCounter = useRef(0); const chatInputRef = useRef(null); const queueRef = useRef([]); @@ -384,6 +395,29 @@ function Chat({ chatInputRef.current?.setText(text); }, []); + // Convene the LLM Council on the given text (or the last user turn if empty). + // Builds the convene instruction from the saved roster and submits it through + // the normal pipeline — the agent runs the panel via PAL MCP and synthesizes. + const handleCouncil = useCallback( + async (text: string) => { + let question = text.trim(); + if (!question) { + // Fall back to the most recent user message. + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === "user" && "content" in m && m.content.trim()) { + question = m.content.trim(); + break; + } + } + } + const cfg = await window.hermesAPI.councilGetConfig(profile); + const prompt = buildCouncilPrompt(cfg, question); + handleSubmitOrQueue(prompt, []); + }, + [messages, profile, handleSubmitOrQueue], + ); + const handlePickFolder = useCallback(async () => { const path = await window.hermesAPI.selectFolder(); if (path) setContextFolder(path); @@ -483,6 +517,19 @@ function Chat({ {contextFolder && worktreeVisible && ( )} + + {factoryVisible && !remoteMode && ( + setFactoryVisible(false)} + onNavigateToTask={onNavigateToTask} + /> + )}
    @@ -501,6 +548,7 @@ function Chat({ readiness={readiness} onSubmit={handleSubmitOrQueue} onQuickAsk={actions.handleQuickAsk} + onCouncil={handleCouncil} onAbort={actions.handleAbort} toolbarExtras={ <> @@ -544,6 +592,18 @@ function Chat({ onClearFolder={handleClearFolder} onToggleWorktree={() => setWorktreeVisible((v) => !v)} /> + { + setFactoryVisible((open) => { + const next = !open; + // Opening = "activate factory": enable the loop if it's off. + if (next && !factory.loopOn) void factory.setLoop(true); + return next; + }); + }} + /> } /> diff --git a/src/renderer/src/screens/Chat/ChatInput.tsx b/src/renderer/src/screens/Chat/ChatInput.tsx index 9efa20a2b..d2de1c570 100644 --- a/src/renderer/src/screens/Chat/ChatInput.tsx +++ b/src/renderer/src/screens/Chat/ChatInput.tsx @@ -8,6 +8,7 @@ import { useImperativeHandle, } from "react"; import { Square as Stop, Slash, Paperclip, Mic, ArrowUp } from "lucide-react"; +import { CouncilIcon } from "../../assets/icons"; import { isImeComposing } from "./keyboard"; import { useI18n } from "../../components/useI18n"; import { SLASH_COMMANDS, type SlashCommand } from "./slashCommands"; @@ -55,6 +56,8 @@ interface ChatInputProps { toolbarExtras?: React.ReactNode; onSubmit: (text: string, attachments: Attachment[]) => void; onQuickAsk: (text: string, attachments: Attachment[]) => void; + /** Convene the LLM Council on the current draft (or last turn if empty). */ + onCouncil?: (text: string) => void; onAbort: () => void; } @@ -71,6 +74,7 @@ export const ChatInput = forwardRef( toolbarExtras, onSubmit, onQuickAsk, + onCouncil, onAbort, }, ref, @@ -269,6 +273,15 @@ export const ChatInput = forwardRef( onQuickAsk(text, sendAttachments); } + // Convene the LLM Council. Uses the current draft; if empty, the parent + // falls back to the last user turn. Clears the composer like a send. + function handleCouncil(): void { + if (!onCouncil) return; + const text = input.trim(); + if (text) clearAfterSend(text); + onCouncil(text); + } + function handleSlashSelect(cmd: SlashCommand): void { setSlashMenuOpen(false); // Local / info commands dispatch immediately — let parent route through onSubmit @@ -546,8 +559,17 @@ export const ChatInput = forwardRef( )}
    - {contextUsage && contextUsage.used > 0 && ( - + {onCouncil && ( + )} {isLoading ? ( )} + {contextUsage && contextUsage.used > 0 && ( + + )}
    diff --git a/src/renderer/src/screens/Chat/FactoryPanel.tsx b/src/renderer/src/screens/Chat/FactoryPanel.tsx new file mode 100644 index 000000000..f0d2150bb --- /dev/null +++ b/src/renderer/src/screens/Chat/FactoryPanel.tsx @@ -0,0 +1,209 @@ +import { memo, useState } from "react"; +import { ShieldCheck, X } from "lucide-react"; +import { useI18n } from "../../components/useI18n"; +import type { GovernBuild, GovernStatus } from "../Factory/types"; + +interface FactoryPanelProps { + status: GovernStatus | null; + loading: boolean; + error: string | null; + unsupported: boolean; + loopOn: boolean; + setLoop: (on: boolean) => Promise; + /** Close the panel (leaves the loop state untouched). */ + onClose: () => void; + /** Optional: focus a build's task in the Kanban tab. */ + onNavigateToTask?: (taskId: string) => void; +} + +const LOOP_STATE_META: Record = { + building: { label: "Building", color: "#61afef" }, + verifying: { label: "Verifying", color: "#e5c07b" }, + correcting: { label: "Correcting", color: "#d19a66" }, + done: { label: "Done", color: "#98c379" }, + parked: { label: "Escalated — needs you", color: "#e06c75" }, +}; + +function BuildCard({ + build, + onNavigateToTask, +}: { + build: GovernBuild; + onNavigateToTask?: (taskId: string) => void; +}): React.JSX.Element { + const meta = LOOP_STATE_META[build.loop_state ?? ""] ?? { + label: build.loop_state ?? "—", + color: "#888", + }; + const parked = build.loop_state === "parked"; + return ( +
    +
    + {onNavigateToTask ? ( + + ) : ( + + {build.title || build.root_id} + + )} + + {meta.label} + +
    +
    + + round{" "} + + {build.verify_round}/{build.max_verify_rounds} + + + {build.last_verdict && ( + + verdict{" "} + + {build.last_verdict} + + + )} +
    + {parked && build.last_summary && ( +
    + Why escalated: {build.last_summary} +
    + )} +
    + ); +} + +/** + * In-chat live view of the dev factory. Pure presentational — Chat owns the + * single `useFactoryStatus` hook instance and passes its result down, so the + * toolbar toggle (which enables the loop on open) and this panel share one + * source of truth (no double polling). + */ +export const FactoryPanel = memo(function FactoryPanel({ + status, + loading, + error, + unsupported, + loopOn, + setLoop, + onClose, + onNavigateToTask, +}: FactoryPanelProps): React.JSX.Element { + const { t } = useI18n(); + const [loopBusy, setLoopBusy] = useState(false); + + const builds = status?.builds ?? []; + + async function toggleLoop(): Promise { + setLoopBusy(true); + try { + await setLoop(!loopOn); + } catch { + /* error surfaced via hook state */ + } finally { + setLoopBusy(false); + } + } + + return ( +
    +
    +
    + + {t("chat.factory.panelTitle")} +
    + +
    + + {/* Factory mode switch — explicit control over the orchestrator loop. */} +
    + + {t("chat.factory.mode")} + + +
    + +
    + {unsupported ? ( +
    + {t("chat.factory.unsupported")} +
    + ) : error ? ( +
    + {error} +
    + ) : loading && !status ? ( +
    + {t("chat.factory.loading")} +
    + ) : ( + <> +
    + {t("chat.factory.builds")} +
    + {builds.length === 0 ? ( +
    + {t("chat.factory.noBuilds")} +
    + ) : ( +
    + {builds.map((b) => ( + + ))} +
    + )} + + )} +
    +
    + ); +}); diff --git a/src/renderer/src/screens/Chat/FactoryToggle.tsx b/src/renderer/src/screens/Chat/FactoryToggle.tsx new file mode 100644 index 000000000..257429e6c --- /dev/null +++ b/src/renderer/src/screens/Chat/FactoryToggle.tsx @@ -0,0 +1,40 @@ +import { memo } from "react"; +import { ShieldCheck } from "lucide-react"; +import { useI18n } from "../../components/useI18n"; + +interface FactoryToggleProps { + /** Hidden in remote/SSH mode (govern status is local-only). */ + show: boolean; + /** Whether the Factory panel is currently open. */ + active: boolean; + /** Toggle the panel (and, when opening, enable factory mode). */ + onToggle: () => void; +} + +/** + * Toolbar chip that opens the in-chat Factory panel. Rendered next to the model + * / reasoning pickers via Chat's `toolbarExtras`, sharing the `.chat-meta-chip` + * style. Opening it also enables the orchestrator closed-loop ("factory mode"); + * the panel itself carries the explicit on/off switch. + */ +export const FactoryToggle = memo(function FactoryToggle({ + show, + active, + onToggle, +}: FactoryToggleProps): React.JSX.Element | null { + const { t } = useI18n(); + if (!show) return null; + + return ( + + ); +}); diff --git a/src/renderer/src/screens/Chat/councilPrompt.ts b/src/renderer/src/screens/Chat/councilPrompt.ts new file mode 100644 index 000000000..93991c68d --- /dev/null +++ b/src/renderer/src/screens/Chat/councilPrompt.ts @@ -0,0 +1,64 @@ +// Builds the "convene the LLM Council" instruction that the desktop submits +// through the normal chat pipeline. The running Hermes agent (Opus 4.8) reads +// this, calls PAL MCP once per seat (free models), then synthesizes as chairman. +// +// The desktop never calls PAL directly or holds any token — it just authors a +// precise instruction the agent fulfils with its existing MCP tools. This keeps +// the renderer thin and reuses 100% of the existing onSubmit contract. +import type { CouncilConfig } from "../../../../shared/council"; + +/** + * Compose the convene prompt from the saved roster + the user's question. + * Seats with a bound model become `mcp_pal_chat` calls carrying the position's + * description as the stance; the agent then weighs and synthesizes. + */ +export function buildCouncilPrompt( + cfg: CouncilConfig, + question: string, +): string { + // Resolve each filled seat: position title + persona + the model that holds it. + const seats = cfg.members + .filter((m) => m.positionId) + .map((m) => { + const pos = cfg.positions.find((p) => p.id === m.positionId); + if (!pos) return null; + return { title: pos.title, description: pos.description, model: m.model, free: m.free }; + }) + .filter((s): s is NonNullable => s !== null); + + const roster = seats + .map( + (s, i) => + `${i + 1}. **${s.title}** — model \`${s.model}\`${s.free ? " (free)" : " (paid)"}\n` + + ` Persona: ${s.description}`, + ) + .join("\n"); + + const chairman = cfg.chairman || "opus-4.8"; + + return [ + "# Convene the LLM Council", + "", + "Act as the **Chairman / orchestrator** of an LLM Council and produce a synthesized decision on the question below.", + "", + "## The question / topic", + question.trim() || "(Use the most recent topic in this conversation.)", + "", + "## How to run the council (use the PAL MCP tools)", + "For EACH seat below, call `mcp_pal_chat` with that seat's model and pass the seat persona as the system stance, asking the seat to answer the question from its specific vantage point. Run the seats, collect every opinion, then as Chairman synthesize.", + "", + "## The council roster", + roster || "(No seats configured. Fall back to a sensible free-model panel and say so.)", + "", + `## Chairman / synthesis (you${chairman === "opus-4.8" ? " — Opus 4.8" : `: ${chairman}`})`, + "After gathering the seats' opinions:", + "1. **Fabrication check** — scan every cited fact/source; discard any that is invented or uncheckable, and say so.", + "2. **Weigh by epistemic strength**, not by vote count — a single sound argument can outweigh the majority.", + "3. Name the **leverage move** (the one insight/action that resolves the core disagreement) in one sentence.", + "4. Name the **productive disagreement** in one sentence.", + "5. End with a **clear, verb-initial recommended action**.", + "", + "## Output format", + "Lead with **## Council Synthesis** (your decisive answer, ~300-450 words). Then a collapsed-friendly **## Seat opinions** section summarizing each seat in 2-3 sentences with its title and model. Be concise; depth goes in the reasoning, not the length.", + ].join("\n"); +} diff --git a/src/renderer/src/screens/Factory/Factory.tsx b/src/renderer/src/screens/Factory/Factory.tsx new file mode 100644 index 000000000..9da9ce700 --- /dev/null +++ b/src/renderer/src/screens/Factory/Factory.tsx @@ -0,0 +1,723 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useI18n } from "../../components/useI18n"; +import type { GovernStatus } from "./types"; + +interface FactoryProps { + visible?: boolean; + onNavigateToTask?: (taskId: string) => void; +} + +const LEVELS = ["monitor", "warn", "gate", "strict"] as const; +const LEVEL_HELP: Record = { + monitor: "Record only — never blocks", + warn: "Surface findings to the worker, never blocks", + gate: "Block critical/high, warn on the rest", + strict: "Block on ANY finding", +}; +type LayoutMode = "control" | "monitor" | "classic"; +const LAYOUT_KEY = "hermes.factory.layout"; +const REFRESH_MS = 15000; + +function Factory({ visible, onNavigateToTask }: FactoryProps = {}): React.JSX.Element { + const { t } = useI18n(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [newGlob, setNewGlob] = useState(""); + const [toast, setToast] = useState<{ msg: string; undo?: () => void } | null>(null); + const [confirm, setConfirm] = useState<{ msg: string; onYes: () => void } | null>(null); + const [autoRefresh, setAutoRefresh] = useState(true); + const [layout, setLayout] = useState(() => { + try { + return (localStorage.getItem(LAYOUT_KEY) as LayoutMode) || "control"; + } catch { + return "control"; + } + }); + // Activity filters + const [fDecision, setFDecision] = useState("all"); + const [fCode, setFCode] = useState("all"); + const [fProfile, setFProfile] = useState("all"); + // Engine-compatible model catalog (cc/ + ag/) for the per-agent picker. + const [models, setModels] = useState([]); + + const toastTimer = useRef | null>(null); + + const load = useCallback(async () => { + setError(null); + try { + const res = await window.hermesAPI.kanbanGovernStatus(); + if (res.success && res.data) { + setStatus(res.data as GovernStatus); + } else if (res.unsupportedMode) { + setError("Factory governance requires a local Hermes install or SSH tunnel mode."); + } else { + setError(res.error || "Failed to load governance status."); + } + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (visible) void load(); + }, [visible, load]); + + // Fetch the model catalog once when the tab first becomes visible. + useEffect(() => { + if (!visible) return; + void window.hermesAPI.kanbanGovernModels().then((res) => { + if (res.success && res.data) setModels(res.data.models || []); + }); + }, [visible]); + + // Auto-refresh while visible. + useEffect(() => { + if (!visible || !autoRefresh) return; + const id = setInterval(() => void load(), REFRESH_MS); + return () => clearInterval(id); + }, [visible, autoRefresh, load]); + + const showToast = useCallback((msg: string, undo?: () => void) => { + if (toastTimer.current) clearTimeout(toastTimer.current); + setToast({ msg, undo }); + toastTimer.current = setTimeout(() => setToast(null), 8000); + }, []); + + const apply = useCallback( + async ( + change: Parameters[0], + opts?: { toast?: string; undo?: () => void }, + ) => { + setBusy(true); + try { + const res = await window.hermesAPI.kanbanGovernSet(change); + if (!res.success) { + setError(res.error || "Change failed."); + } else if (opts?.toast) { + showToast(opts.toast, opts.undo); + } + await load(); + } finally { + setBusy(false); + } + }, + [load, showToast], + ); + + const toggleKill = useCallback( + async (on: boolean) => { + setBusy(true); + try { + await window.hermesAPI.kanbanGovernKillSwitch(on); + showToast(on ? "Factory halted." : "Factory resumed."); + await load(); + } finally { + setBusy(false); + } + }, + [load, showToast], + ); + + const askConfirm = (msg: string, onYes: () => void) => setConfirm({ msg, onYes }); + + // Change a profile's model. "__advanced__" prompts for a custom id; a cx/ id + // is warned about here (the engine also hard-rejects it). + const changeModel = useCallback( + (profile: string, value: string, prev: string | null) => { + let id = value; + if (value === "__advanced__") { + const typed = window.prompt( + `Custom model id for ${profile} (cc/ or ag/ only — cx/ is rejected: it breaks tool calls):`, + prev || "", + ); + if (!typed) return; + id = typed.trim(); + } + if (id.toLowerCase().startsWith("cx/")) { + setError(`'${id}' is a cx/ (OpenAI-shape) id — it breaks the adapter and will be rejected. Use cc/ or ag/.`); + return; + } + const undo = prev && prev !== id + ? () => void apply({ model: prev, profile }, { toast: `Reverted ${profile} model → ${prev}` }) + : undefined; + void apply({ model: id, profile }, { toast: `${profile} model → ${id}`, undo }); + }, + [apply], + ); + + if (loading && !status) { + return ( +
    +

    {t("navigation.factory")}

    +
    +
    + ); + } + if (error && !status) { + return ( +
    +

    {t("navigation.factory")}

    +
    +

    {error}

    + +
    +
    + ); + } + + const g = status!.governance; + const bud = status!.budget; + const ks = bud.kill_switch; + const orch = status!.orchestration ?? {}; + const allBlocks = status!.activity.recent_governance_blocks ?? []; + const builds = status!.activity.recent_builds ?? []; + const changeLog = status!.activity.change_log ?? []; + // Orchestrator closed-loop: live builds + loop config. + const loopBuilds = status!.builds ?? []; + const loopOn = orch.orchestrator_loop === true || orch.orchestrator_loop === "true"; + + // ---- filtered activity ---- + const blocks = allBlocks.filter((b) => { + if (fDecision !== "all" && String(b.decision) !== fDecision) return false; + const f = ((b.findings as Array>) || [])[0] || {}; + if (fCode !== "all" && String(f.code) !== fCode) return false; + if (fProfile !== "all" && String(b.profile ?? "") !== fProfile) return false; + return true; + }); + const blocksToday = allBlocks.filter((b) => + String(b.ts ?? "").slice(0, 10) === new Date().toISOString().slice(0, 10), + ).length; + const hybridCount = g.profiles.filter((p) => p.hybrid).length; + + const setLayoutMode = (m: LayoutMode) => { + setLayout(m); + try { localStorage.setItem(LAYOUT_KEY, m); } catch { /* ignore */ } + }; + + // ===== SECTION RENDERERS ===== + const StatusStrip = ( +
    +
    + Oversight + {g.level_uniform ? g.level : "MIXED"} +
    +
    + State + + {ks.active ? "HALTED" : "Running"} + +
    +
    + Secrets + {g.secret_scan_patterns} + entropy +
    +
    + Hybrid + {hybridCount}/{g.profiles.length} +
    +
    + Blocks today + {blocksToday} +
    +
    + ); + + const Governance = ( +
    +
    GOVERNANCE
    +

    + Governance guides the factory — it warns + logs and lets autonomous builds + continue. Only a hardcoded secret or “Halt all agents” stops a worker. +

    + +
    + +
    + {LEVELS.map((lvl) => ( + + ))} +
    +

    {LEVEL_HELP[g.level] || ""}

    +
    + +
    + +
    + + +
    +
    + + {/* Editable per-profile table */} + + + + + + + + {g.profiles.map((p) => ( + + + + + + + + + ))} + +
    ProfileLevelSecretsHybridModelProtected
    {p.profile} + + + + + + + + {p.protected_paths.length}
    + + {/* Protected paths editor */} +
    + +
    + {(g.profiles[0]?.protected_paths ?? []).map((glob) => ( + + {glob} + + + ))} +
    +
    + setNewGlob(e.target.value)} /> + +
    +
    +
    + ); + + const Budget = ( +
    +
    BUDGET
    +
    + + State: {ks.active ? "HALTED" : "Running"} + + +
    +
    + + +
    +

    + New cards inherit these ceilings; the autonomous self-correct loop is also bounded by a + per-block retry cap of {bud.per_block_retry_cap ?? "—"}. Unlimited iterations means the + retry cap is the only backstop. +

    +
    + ); + + const profileNames = g.profiles.map((p) => p.profile); + const orchProfile = String(orch.orchestrator_profile ?? ""); + const defAssignee = String(orch.default_assignee ?? ""); + const autoDec = orch.auto_decompose === true || orch.auto_decompose === "true"; + const Orchestration = ( +
    +
    ORCHESTRATION
    +

    + Which agent runs the build, who catches unrouted work, and how aggressively the board fans out. +

    +
    + + + + + + + + + + + { + const v = parseInt(e.target.value, 10); + if (v && v !== Number(orch.auto_decompose_per_tick)) + void apply({ autoDecomposePerTick: v }, { toast: `Per-tick → ${v}` }); + }} /> + + + { + const v = parseInt(e.target.value, 10); + if (v && v !== Number(orch.max_in_progress_per_profile)) + void apply({ maxInProgress: v }, { toast: `Max in-progress → ${v}` }); + }} /> + + + + + + + { + const v = parseInt(e.target.value, 10); + if (v && v !== Number(orch.max_verify_rounds ?? 3)) + void apply({ maxVerifyRounds: v }, { toast: `Max verify rounds → ${v}` }); + }} /> +
    + {/* Read-only lower-level knobs */} + + + {["failure_limit", "dispatch_in_gateway"].map((k) => ( + + + + + ))} + +
    {k}{orch[k] === null || orch[k] === undefined ? "—" : String(orch[k])}
    +
    + ); + + const Activity = ( +
    +
    +
    ACTIVITY — governance blocks
    + +
    + {/* Filters */} +
    + + + +
    + {blocks.length === 0 ? ( +

    No governance blocks match.

    + ) : ( + + + + + + {blocks.slice(0, 40).map((b, i) => { + const f = ((b.findings as Array>) || [])[0] || {}; + const dec = String(b.decision ?? "?"); + const tid = String(b.task_id ?? ""); + return ( + tid && onNavigateToTask?.(tid)} + title={tid ? `Open task ${tid}` : ""}> + + + + + + + ); + })} + +
    WhenDecisionCodeProfilePath / message
    {String(b.ts ?? "").slice(0, 19)}{dec}{String(f.code ?? "—")}{String(b.profile ?? "—")} + {String(f.message ?? f.path ?? "")} +
    + )} + + {builds.length > 0 && ( + <> +
    Recent builds
    + + + + {builds.slice(0, 15).map((b, i) => ( + + + + ))} + +
    WhenTaskOutcomeSummary
    {String(b.ts ?? "").slice(0, 19)}{String(b.task_id ?? b.root ?? "")}{String(b.outcome ?? "")}{String(b.summary ?? "")}
    + + )} + + {changeLog.length > 0 && ( + <> +
    Settings change log
    + + + + {changeLog.slice(0, 20).map((c, i) => ( + + + ))} + +
    WhenActionScopeKeyNew value
    {String(c.ts ?? "").slice(0, 19)}{String(c.action ?? "")}{String(c.target ?? "")}{String(c.key ?? "")}{String(c.new ?? "")}
    + + )} +
    + ); + + // ---- BUILDS: the orchestrator closed-loop oversight pane ---- + const loopStateMeta: Record = { + building: { label: "Building", color: "#61afef" }, + verifying: { label: "Verifying", color: "#e5c07b" }, + correcting: { label: "Correcting", color: "#d19a66" }, + done: { label: "Done", color: "#98c379" }, + parked: { label: "Escalated — needs you", color: "#e06c75" }, + }; + const Builds = ( +
    +
    +
    BUILDS — orchestrator oversight
    + + closed-loop {loopOn ? "ON" : "OFF"} + +
    +

    + Each build the orchestrator is overseeing — its goal, the acceptance criteria it must meet, + and where it is in the verify → correct → done loop. +

    + {!loopOn && ( +
    + The closed-loop is off — builds fan out once and assemble without verification. Turn it on in + Orchestration → Closed-loop oversight to have the orchestrator verify each build against its + acceptance criteria and command corrections. +
    + )} + {loopBuilds.length === 0 ? ( +
    No tracked builds yet.
    + ) : ( +
    + {loopBuilds.map((b) => { + const meta = loopStateMeta[b.loop_state ?? ""] ?? { label: b.loop_state ?? "—", color: "#888" }; + const parked = b.loop_state === "parked"; + return ( +
    +
    + + + {meta.label} + +
    +
    + orchestrator: {b.orchestrator || "—"} + verify round: {b.verify_round}/{b.max_verify_rounds} + {b.last_verdict && last verdict: {b.last_verdict}} +
    + {b.acceptance.length > 0 && ( +
    + Acceptance criteria ({b.acceptance.length}) +
      + {b.acceptance.map((c, i) =>
    • {c}
    • )} +
    +
    + )} + {parked && b.last_summary && ( +
    + Why escalated: {b.last_summary} +
    + )} +
    + ); + })} +
    + )} +
    + ); + + const order: Record = { + control: [Governance, Builds, Budget, Orchestration, Activity], + monitor: [Builds, Activity, Governance, Budget, Orchestration], + classic: [Governance, Budget, Orchestration, Builds, Activity], + }; + + return ( +
    +
    +

    {t("navigation.factory")}

    +
    + + +
    +
    +

    + Governance, budget, orchestration, and live activity for the autonomous dev factory. +

    + + {StatusStrip} + {order[layout]} + + {/* Confirm dialog */} + {confirm && ( +
    setConfirm(null)}> +
    e.stopPropagation()}> +

    {confirm.msg}

    +
    + + +
    +
    +
    + )} + + {/* Undo toast */} + {toast && ( +
    + {toast.msg} + {toast.undo && ( + + )} +
    + )} + + {error && status && ( +
    + {error} +
    + )} +
    + ); +} + +export default Factory; diff --git a/src/renderer/src/screens/Factory/types.ts b/src/renderer/src/screens/Factory/types.ts new file mode 100644 index 000000000..1bf5e36ba --- /dev/null +++ b/src/renderer/src/screens/Factory/types.ts @@ -0,0 +1,56 @@ +// Mirror of the engine's `hermes kanban govern --json` document. +// Shared between the Factory tab (screens/Factory/Factory.tsx) and the in-chat +// Factory panel (screens/Chat/FactoryPanel.tsx + shared/useFactoryStatus.ts) so +// the shape has a single source of truth. + +export interface GovernProfileState { + profile: string; + level: string | null; + protected_paths: string[]; + secret_scan: boolean; + hybrid: boolean; + model: string | null; + governed: boolean; +} + +export interface GovernBuild { + root_id: string; + title: string | null; + task_status: string | null; + orchestrator: string | null; + loop_state: string | null; + verify_round: number; + max_verify_rounds: number; + acceptance: string[]; + last_verdict: string | null; + last_summary: string | null; + unmet: Array>; + updated_at: string | null; +} + +export interface GovernStatus { + schema: number; + governance: { + valid_levels: string[]; + default_level: string; + level: string; + level_uniform: boolean; + secret_scan_patterns: number; + profiles: GovernProfileState[]; + }; + budget: { + kill_switch: { active: boolean; paths: string[]; present_at: string[] }; + dimensions: string[]; + default_max_iterations: number | null; + default_wallclock_seconds: number | null; + per_block_retry_cap: number | null; + }; + orchestration: Record; + builds?: GovernBuild[]; + activity: { + recent_governance_blocks: Array>; + recent_budget_events: Array>; + recent_builds: Array>; + change_log: Array>; + }; +} diff --git a/src/renderer/src/screens/Kanban/Kanban.tsx b/src/renderer/src/screens/Kanban/Kanban.tsx index 17e041e1a..aafff7041 100644 --- a/src/renderer/src/screens/Kanban/Kanban.tsx +++ b/src/renderer/src/screens/Kanban/Kanban.tsx @@ -16,6 +16,10 @@ import { useI18n } from "../../components/useI18n"; interface KanbanProps { profile?: string; visible?: boolean; + /** When set (e.g. from the Factory Builds pane), open this task's detail. */ + focusTaskId?: string | null; + /** Called once the focus request has been consumed, so the parent can clear it. */ + onFocusHandled?: () => void; } interface KanbanTask { @@ -136,7 +140,7 @@ function ageLabel(createdAt: number | null): string { return `${Math.floor(seconds / 86400)}d`; } -function Kanban({ profile, visible }: KanbanProps): React.JSX.Element { +function Kanban({ profile, visible, focusTaskId, onFocusHandled }: KanbanProps): React.JSX.Element { const { t } = useI18n(); const [boards, setBoards] = useState([]); const [tasks, setTasks] = useState([]); @@ -315,6 +319,15 @@ function Kanban({ profile, visible }: KanbanProps): React.JSX.Element { }; }, [detailTaskId, profile]); + // External focus request (e.g. clicking a build in the Factory pane): open + // that task's detail once this screen is visible, then notify the parent so + // the one-shot request is cleared (re-clicking the same task re-focuses). + useEffect(() => { + if (!visible || !focusTaskId) return; + setDetailTaskId(focusTaskId); + onFocusHandled?.(); + }, [visible, focusTaskId, onFocusHandled]); + const tasksByStatus = useMemo(() => { const grouped: Record = {}; for (const col of COLUMNS) grouped[col.key] = []; diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index 7ce4de4a5..379d262b8 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -27,6 +27,7 @@ import Models from "../Models/Models"; import Providers from "../Providers/Providers"; import Schedules from "../Schedules/Schedules"; import Kanban from "../Kanban/Kanban"; +import Factory from "../Factory/Factory"; import RemoteNotice from "../../components/RemoteNotice"; import VerifyWarningBanner from "../../components/VerifyWarningBanner"; import hermeslogo from "../../assets/hermes-one.svg"; @@ -43,6 +44,7 @@ import { KeyRound, Timer, Kanban as KanbanIcon, + ShieldCheck as FactoryIcon, Download, PanelLeftClose, PanelLeftOpen, @@ -65,6 +67,7 @@ type View = | "tools" | "schedules" | "kanban" + | "factory" | "gateway" | "settings"; @@ -76,6 +79,7 @@ const NAV_ITEMS: { view: View; icon: LucideIcon; labelKey: string }[] = [ // "Manage profiles" action rather than a top-level nav item. { view: "office", icon: Building, labelKey: "navigation.office" }, { view: "kanban", icon: KanbanIcon, labelKey: "navigation.kanban" }, + { view: "factory", icon: FactoryIcon, labelKey: "navigation.factory" }, { view: "models", icon: Layers, labelKey: "navigation.models" }, { view: "providers", icon: KeyRound, labelKey: "navigation.providers" }, // "skills" lives under the Discover tab (installed + community), so it's no @@ -198,6 +202,8 @@ function Layout({ kind: "skills" | "mcps"; nonce: number; } | null>(null); + // Set by the Factory Builds pane to open a build's task detail in Kanban. + const [kanbanFocusTaskId, setKanbanFocusTaskId] = useState(null); const paneStyle = (target: View): React.CSSProperties => ({ display: view === target ? "flex" : "none", @@ -219,6 +225,14 @@ function Layout({ [goTo], ); + const focusKanbanTask = useCallback( + (taskId: string) => { + setKanbanFocusTaskId(taskId); + goTo("kanban"); + }, + [goTo], + ); + // Re-check remote mode on tab switch (picks up Settings changes) useEffect(() => { window.hermesAPI.isRemoteOnlyMode().then(setRemoteMode); @@ -418,7 +432,7 @@ function Layout({ ); const handleResumeSession = useCallback( - async (sessionId: string) => { + async (sessionId: string, sessionProfile?: string) => { // Already open as a live run? Re-attach to it (keeps live streaming). const live = findRunBySession(runs, sessionId); if (live) { @@ -432,13 +446,23 @@ function Layout({ resumingRef.current.add(sessionId); setResumingSessionId(sessionId); try { - const items = (await window.hermesAPI.getSessionMessages( + // Resolve which profile's state.db holds this session. If the caller + // didn't say (legacy code path), fall back to the active profile — + // safe for the default profile and for code that hasn't been updated. + const targetProfile = sessionProfile || activeProfile; + const items = (await window.hermesAPI.getSessionMessagesFromProfile( + targetProfile, sessionId, - )) as DbHistoryItem[]; - const run = mintRun(activeProfile, dbItemsToChatMessages(items)); + )) as unknown as DbHistoryItem[]; + const run = mintRun(targetProfile, dbItemsToChatMessages(items)); run.sessionId = sessionId; setRuns((prev) => [...prev, run]); setActiveRunId(run.runId); + // Switching active_profile is the critical correctness step — without + // it the resumed chat would run under the wrong profile's gateway. + if (targetProfile !== activeProfile) { + setActiveProfile(targetProfile); + } goTo("chat"); } finally { resumingRef.current.delete(sessionId); @@ -635,6 +659,7 @@ function Layout({ onLoadingChange={handleRunLoading} onSessionIdChange={handleRunSessionId} onTitleChange={handleRunTitle} + onNavigateToTask={focusKanbanTask} />
    ))} @@ -752,7 +777,25 @@ function Layout({ {remoteMode ? ( ) : ( - + setKanbanFocusTaskId(null)} + /> + )} + + )} + + {visitedViews.has("factory") && ( +
    + {remoteMode ? ( + + ) : ( + )}
    )} diff --git a/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx b/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx index 1967890e2..90c708dd5 100644 --- a/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx +++ b/src/renderer/src/screens/Layout/SidebarRecentSessions.tsx @@ -5,6 +5,7 @@ import { Circle, Spinner } from "../../assets/icons"; interface RecentSession { id: string; title: string; + profile: string; } // ChatGPT-style recent list under the Sessions nav item. @@ -22,7 +23,12 @@ const REFRESH_THROTTLE_MS = 5_000; function sameSessions(a: RecentSession[], b: RecentSession[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { - if (a[i].id !== b[i].id || a[i].title !== b[i].title) return false; + if ( + a[i].id !== b[i].id || + a[i].title !== b[i].title || + a[i].profile !== b[i].profile + ) + return false; } return true; } @@ -48,24 +54,34 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ onSelect, }: { open: boolean; - /** Active profile — the list is per-profile, so switching forces a reload. */ + /** + * Active profile — recency reorders when it changes, so a switch forces a + * reload. The list itself now aggregates across ALL profiles (so older + * named-profile sessions are reachable from the sidebar too). + */ activeProfile: string; currentSessionId: string | null; /** Session ids of every run currently generating (multiple run at once). */ loadingSessionIds: Set; /** A session whose history is being fetched for resume (transient spinner). */ resumingSessionId: string | null; - onSelect: (sessionId: string) => void; + onSelect: (sessionId: string, profile: string) => void; }): React.JSX.Element | null { const { t } = useI18n(); const [sessions, setSessions] = useState([]); const lastRefreshRef = useRef(0); const applySessions = useCallback( - (list: Array<{ id: string; title: string }>): void => { + ( + list: Array<{ id: string; title: string | null; profile?: string }>, + ): void => { const next = list .slice(0, RECENT_SESSIONS_LIMIT) - .map(({ id, title }) => ({ id, title })); + .map(({ id, title, profile }) => ({ + id, + title: title ?? "", + profile: profile ?? "default", + })); // Skip the state update (and re-render) when nothing changed — the // common case for periodic refreshes. setSessions((prev) => (sameSessions(prev, next) ? prev : next)); @@ -79,7 +95,7 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ if (!force && now - lastRefreshRef.current < REFRESH_THROTTLE_MS) return; lastRefreshRef.current = now; try { - const synced = await window.hermesAPI.syncSessionCache(); + const synced = await window.hermesAPI.syncAllSessionCaches(); applySessions(synced); } catch { // keep whatever we had — the list is best-effort UI sugar @@ -97,7 +113,7 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ let cancelled = false; void (async () => { try { - const cached = await window.hermesAPI.listCachedSessions( + const cached = await window.hermesAPI.listAllSessions( RECENT_SESSIONS_LIMIT, ); if (!cancelled && cached.length > 0) applySessions(cached); @@ -106,7 +122,7 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ } lastRefreshRef.current = Date.now(); try { - const synced = await window.hermesAPI.syncSessionCache(); + const synced = await window.hermesAPI.syncAllSessionCaches(); if (!cancelled) applySessions(synced); } catch { // cache read above already painted something @@ -173,7 +189,7 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({ key={s.id} type="button" className={`sidebar-recent-session ${active ? "active" : ""}`} - onClick={() => onSelect(s.id)} + onClick={() => onSelect(s.id, s.profile)} title={title} tabIndex={expanded ? 0 : -1} > diff --git a/src/renderer/src/screens/Models/CouncilTab.tsx b/src/renderer/src/screens/Models/CouncilTab.tsx new file mode 100644 index 000000000..4d79d5a3c --- /dev/null +++ b/src/renderer/src/screens/Models/CouncilTab.tsx @@ -0,0 +1,496 @@ +import { useState, useEffect, useCallback } from "react"; +import toast from "react-hot-toast"; +import { + Plus, + Trash, + Pencil, + X, + Check, + Crown, + AdvisorIcon, + Gauge, + ThumbsUp, + ThumbsDown, + Sparkles, +} from "../../assets/icons"; +import { useI18n } from "../../components/useI18n"; +import type { + CouncilConfig, + CouncilModelAdvice, + CouncilAdviceResult, +} from "../../../../shared/council"; + +interface CouncilTabProps { + visible?: boolean; + profile?: string; +} + +const TASK_KINDS = [ + "architecture", + "coding", + "security", + "uiux", + "quick-check", + "research", + "general", +] as const; + +function tierClass(tier: string): string { + return `council-tier council-tier-${tier}`; +} + +export function CouncilTab({ visible, profile }: CouncilTabProps): React.JSX.Element { + const { t } = useI18n(); + const [cfg, setCfg] = useState(null); + const [pool, setPool] = useState([]); + + // Add-member form + const [showAddMember, setShowAddMember] = useState(false); + const [newModel, setNewModel] = useState(""); + + // Position editor modal + const [editingPos, setEditingPos] = useState<{ id?: string; title: string; description: string } | null>(null); + + // Advisor + const [advisorTask, setAdvisorTask] = useState("general"); + const [advisorPreferFree, setAdvisorPreferFree] = useState(true); + const [advice, setAdvice] = useState([]); + + const load = useCallback(async () => { + const [config, modelPool] = await Promise.all([ + window.hermesAPI.councilGetConfig(profile), + window.hermesAPI.councilModelAdvice(), + ]); + setCfg(config); + setPool(modelPool); + }, [profile]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (visible) void load(); + }, [visible, load]); + + const runAdvisor = useCallback(async () => { + const results = await window.hermesAPI.councilRecommendModels( + advisorTask, + advisorPreferFree, + ); + setAdvice(results); + }, [advisorTask, advisorPreferFree]); + + if (!cfg) { + return
    {t("council.loading")}
    ; + } + + // ---- Members ---- + async function handleAddMember(): Promise { + const model = newModel.trim(); + if (!model) return; + const known = pool.find((p) => p.model === model || p.label === model); + const next = await window.hermesAPI.councilAddMember( + { model, label: known?.label || model, free: known?.free ?? false }, + profile, + ); + setCfg(next); + setNewModel(""); + setShowAddMember(false); + toast.success(t("council.memberAdded", { model: known?.label || model })); + } + + async function handleRemoveMember(id: string): Promise { + setCfg(await window.hermesAPI.councilRemoveMember(id, profile)); + } + + async function handleAssign(memberId: string, positionId: string): Promise { + setCfg( + await window.hermesAPI.councilAssignPosition( + memberId, + positionId || null, + profile, + ), + ); + } + + async function handleSetChairman(model: string): Promise { + setCfg(await window.hermesAPI.councilSetChairman(model, profile)); + } + + // ---- Positions ---- + async function handleSavePosition(): Promise { + if (!editingPos || !editingPos.title.trim()) return; + setCfg( + await window.hermesAPI.councilUpsertPosition( + { + id: editingPos.id, + title: editingPos.title.trim(), + description: editingPos.description.trim(), + }, + profile, + ), + ); + setEditingPos(null); + toast.success(t("council.positionSaved")); + } + + async function handleDeletePosition(id: string): Promise { + setCfg(await window.hermesAPI.councilDeletePosition(id, profile)); + } + + async function handleFeedback(id: string, vote: "up" | "down"): Promise { + setCfg(await window.hermesAPI.councilPositionFeedback(id, vote, profile)); + } + + async function handleResolveProposed(id: string, accept: boolean): Promise { + setCfg(await window.hermesAPI.councilResolveDescription(id, accept, profile)); + } + + async function handleReset(): Promise { + setCfg(await window.hermesAPI.councilResetConfig(profile)); + toast.success(t("council.resetDone")); + } + + const freeCount = cfg.members.filter((m) => m.free).length; + const paidCount = cfg.members.length - freeCount; + + return ( +
    + {/* Intro + cost honesty */} +
    + {t("council.intro")} + + {t("council.costChip", { free: freeCount, paid: paidCount })} + +
    + + {/* ── Chairman ── */} +
    +
    + +

    {t("council.chairmanTitle")}

    +
    +
    {t("council.chairmanHint")}
    + +
    + + {/* ── Panel members ── */} +
    +
    +

    {t("council.membersTitle")}

    + +
    + + {showAddMember && ( +
    + setNewModel(e.target.value)} + placeholder={t("council.modelPlaceholder")} + autoFocus + /> + + {pool.map((m) => ( + + ))} + + +
    + )} + + {cfg.members.length === 0 ? ( +
    {t("council.noMembers")}
    + ) : ( +
    + {cfg.members.map((m) => ( +
    +
    + {m.label} + + {m.free ? t("council.free") : t("council.paid")} + + +
    + + +
    + ))} +
    + )} +
    + + {/* ── Positions (with self-learning descriptions) ── */} +
    +
    +

    {t("council.positionsTitle")}

    + +
    +
    {t("council.positionsHint")}
    + +
    + {cfg.positions.map((p) => ( +
    +
    + {p.title} + {p.builtin && ( + + {t("council.builtin")} + + )} +
    + + {!p.builtin && ( + + )} +
    +
    +
    {p.description}
    + + {/* Self-learning feedback row */} +
    + + + {t("council.learnHint")} +
    + + {/* Agent-proposed refinement awaiting accept/reject */} + {p.proposedDescription && ( +
    +
    + {t("council.proposedLabel")} +
    +
    {p.proposedDescription}
    +
    + + +
    +
    + )} +
    + ))} +
    +
    + + {/* ── Advisor (item 2e) ── */} +
    +
    + +

    {t("council.advisorTitle")}

    +
    +
    {t("council.advisorHint")}
    + +
    + + + +
    + + {advice.length > 0 && ( +
    + {advice.slice(0, 6).map((a, i) => ( +
    +
    + {i === 0 && {t("council.bestPick")}} + {a.label} + + {a.free ? t("council.free") : t("council.paid")} + +
    +
    + + {t("council.accuracy")}: {t(`council.tier.${a.accuracy}`)} + + + {t(`council.speed.${a.speed}`)} + + + {a.contextK >= 1000 ? `${a.contextK / 1000}M` : `${a.contextK}K`} + +
    +
    {a.strength}
    + +
    + ))} +
    + )} +
    + + + + {/* ── Position editor modal ── */} + {editingPos && ( +
    setEditingPos(null)}> +
    e.stopPropagation()}> +
    +

    + {editingPos.id ? t("council.editPosition") : t("council.addPosition")} +

    + +
    +
    +
    + + setEditingPos({ ...editingPos, title: e.target.value })} + placeholder={t("council.positionNamePlaceholder")} + autoFocus + /> +
    +
    + +