SLOPSHOPPER

sg-hermes

Snow Gloves Hermes console: /hermes shows the live event stream, tests how a task title routes, and replays recent events through the current skill hooks; can…

newpanecommandprocessnetworktimer
v0.1.0no licenseupdated 2026-10-10Sheshiyer/snow-gloves-os/mods/sg-hermes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sg-hermes
│ ┃ Hermes ✕ › fix the failing auth test and add an audit log call │ ┃ Hermes ○ 1: live 2: route 3: replay │ ┃ Hermes answered 0 ⏺ Read(src/auth.ts) │ ┃ 0 events ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hermes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Hermes
Hermes ○ 1: live 2: route 3: replay Hermes answered 0 0 events
README

<img src="docs/assets/snowgloves-hero.png" width="100%" alt="Snow Gloves OS — Hand-In-Glove Business Operations Platform" />

Snow Gloves OS

Hand-In-Glove Business Operations Platform

<!-- og:image: docs/assets/snowgloves-og.png (upload via repo Settings → Social preview) -->

<!-- readme-gen:start:badges -->

Version Status Release License Hermes

<!-- readme-gen:end:badges -->

<!-- readme-gen:start:tech-stack --> <img src="https://skillicons.dev/icons?i=py,bash,yaml,rust,tauri,github,githubactions&theme=dark" alt="Tech Stack" /> <!-- readme-gen:end:tech-stack -->

<!-- readme-gen:start:social -->

Stars Last Commit Issues

<!-- readme-gen:end:social -->

Run a business as if a senior team were watching it 24/7. Snow Gloves OS is a reusable, tenant-scoped operations platform. It wraps your tools (G-Stack connectors), your knowledge (NVIDIA embeddings), your judgement (an interpretation layer), and your actions (Hermes + Paperclip orchestration), so events don't get dropped, decisions are auditable, and risky actions are gated. It is agent-agnostic: you pick the runtime (Claude Code, Codex, Cursor, OpenCode, Grok, Hermes, OpenClaw, or any other agent), and Snow Gloves renders its skills, MCP servers, and rules into that runtime's own format.

Docs for new users: snow-gloves-wiki — how to use the app and the ecosystem (install, pick a runtime, onboard, modules, a quiet week). Slides, audio, and video live there. The modules dashboard is the catalog, not the human guide.

The Infrastructure cockpit connects the playable Infra Block city to catalog, agent routing, runtime adapters, connector gates, scoped tenant metadata, wing profiles, activity records and acceptance evidence. Run python3 scripts/infra_cockpit.py and npm --prefix apps/infra-block run dev, then open http://127.0.0.1:18760/. The workspace supports source inspection and gate-checked proposal exports. Private instance metadata requires an explicit --data-root; execution and enablement remain outside this read-only interface.

<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />

✨ Highlights

<table> <tr> <td width="50%" valign="top">

🧤 Hand-In-Glove

Seven specialized agents (CEO, CTO, Chief of Staff, Librarian, Interpreter, Dispatcher, Sentinel) work as one operations team, each with IDENTITY · SOUL · TOOLS · SKILLS · HEARTBEAT · MEMORY · EVOLUTION.

</td> <td width="50%" valign="top">

🧭 Skill Orchestration

A dedicated Chief of Staff routes four native snowgloves: skills via glob-matched hooks so CEO/CTO stay strategic. inference-sh and Explee are catalog packs, not core. Enable a module and it is callable across agents.

</td> </tr> <tr> <td width="50%" valign="top">

🗂 Modules & Catalog

132 catalog cards (skills, MCP servers, plugins, playbooks, connectors) harvested from X, Instagram, Field Theory, founders-kit, and marketingskills — including optional inference-sh and Explee packs. Every card is a pointer with a disposition: add, pointer, hold, or refuse.

</td> <td width="50%" valign="top">

🔀 Runtime Adapters

One adapter.yaml per runtime says where skills, MCP config, and rules live and which question tool drives plan mode. The founder picks the runtime; nothing is hard-wired to one agent.

</td> </tr> <tr> <td width="50%" valign="top">

🔌 G-Stack Connector Fabric

Composio-like scoped connectors (Gmail, Calendar, Drive, Slack, PMS, Accounting, HRIS). Per-capability risk + approval flags. Webhooks signed and verified.

</td> <td width="50%" valign="top">

🧠 NVIDIA Embeddings

Tenant-isolated vector index (nv-embedqa-e5-v5, 1024-dim). Plug into NVIDIA NIM with NVIDIA_API_KEY, or run offline with the deterministic stub backend.

</td> </tr> <tr> <td width="50%" valign="top">

🛰️ Hermes Event Bus

Minimal HTTP listener on port 4100 (scripts/hermes.py). POST /publish, GET /events, POST /test/e2e for end-to-end smoke. Every event is append-logged to _audit/hermes-events.jsonl.

</td> <td width="50%" valign="top">

🛡️ Sentinel Drift Sweep

Daily aggregation of hook usage, fallbacks, escalations, and top skills per agent. Auto-appended to each agent's EVOLUTION.md for self-improving loops.

</td> </tr> </table>

<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />

🚀 Quick Start

git clone https://github.com/Sheshiyer/snow-gloves-os.git
cd snow-gloves-os
./scripts/install.sh              # deps + print next steps (does not go live)
make doctor                       # pre-flight
make smoke                        # bus up, test event, stub embed, Sentinel
make walk                         # machine-check CoS graph + native inner loops
make graph-upgrade                # learning-edge dry-run (constraints; hooks gated)
make onboard-prompt R=claude      # print the plan-mode interview for your runtime
make test                         # pytest

Fleet: three Mac minis

Snow Gloves runs on three office Mac minis by function (marketing, design, coding) over Tailscale. The Coding Mac hosts OmniRoute :20128 and Hermes :4100 for the whole fleet. A brand (tenant) is enabled on a wing (node), and make fleet-render W=<wing> T=<tenant> R=<runtime> writes that brand's modules and the wing's MCPs into the runtime's own files. Start with docs/fleet/README.md; make fleet-doctor tells a mini whether it is wired as its wing says.

New Mac Mini (use the product, do not compile Tauri): docs/MAC-MINI-SETUP.md. Emulate the desktop wizard on a dev Mac with make app-install then make app-dev — apps/onboarding/README.md. The fleet inventory is fleet.yaml in your private data checkout (template: fleet.example.yaml), and each wing's machine profile lives in nodes/ (nodes/<wing>/node.yaml; templates here, the instance copy in the data checkout wins).

Private data

This repository is the platform. Instance data (brand tenants, the fleet inventory and wing profiles, receipts, decisions, planning state) lives in a separate private checkout, snow-gloves-ops. Point the scripts at it:

export SNOWGLOVES_DATA=/path/to/snow-gloves-ops   # tenants/, fleet.yaml, nodes/, _audit/

Unset, everything resolves to this checkout and its public fixtures (_demo, acme, tryambakam-noesis), which the local validation commands use; make smoke and make walk always use the fixtures. The resolver is scripts/lib/paths.py; scripts/onboard.py, node_profile.py, graph_upgrade.py and upgrade.py also take --data DIR. Never commit tenants, fleet.yaml, receipts or planning state here.

Planned fresh-node CLI onboarding: layered setup plan and Claude start prompt. This covers an always-on Mac mini workspace, developer tooling, Temperance/OmniRoute, secrets, and selected brand capabilities. It is a planning candidate; the node bootstrap CLI is not implemented yet.

TargetPurpose
make installBootstrap (paperclipai + python deps)
make doctorPre-flight diagnostic
make onboardInteractive tenant + sources prompt (legacy flow)
make onboard-prompt R=<runtime>Print the plan-mode interview prompt for a runtime
make catalog / make catalog-checkRebuild catalog/modules.json + registry.yaml / fail if stale
make legacy-checkFail if pre-Hermes bus names remain (skips .bak-*)
make siteBuild the static dashboard site (npm run build:site)
make hermesForeground Hermes listener on :4100
make smokeHermes → e2e → bridge dry-run → embed stub → sentinel
make walkRoute fixtures through CoS; native produce/check/correct; receipt JSON
make tuiOnboarding TUI (agent vs manual; --headless for agents)
make graph-upgrade [T=<slug>] [WRITE=1]Learning edge dry-run (or apply constraints; hook diffs need approval)
make embed T=<tenant>Run NVIDIA embed worker (QUIET=1 for cron)
make sentinelDaily drift sweep
make release-dry V=x.y.zPreview a platform version bump
make release V=x.y.zBump every version file, rebuild catalog, commit, tag (no push)
make release-push V=x.y.zPush HEAD + tag; signing and publication require explicit manual steps
make release-checkVerify all version files agree with VERSION
make upgrade [T=<slug>] [WRITE=1]Dry-run (or apply) tenant platform migrations
make kill-hermesFree port 4100
make fleet-doctor [W=<wing>] / fleet-render W= T= R= / fleet-enable W= T= / fleet-connect W= / fleet-kit-export / fleet-remote-access W=Three-mini fleet: wing diagnostics, brand x wing x runtime render, enable, Tailscale shell, gateway kit, remote-access plan (docs/fleet/README.md)
make app-install / app-dev / app-buildTauri onboarding app: npm install, tauri dev (GUI), release bundle

<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />

<!-- readme-gen:start:architecture -->

🏗 Architecture

graph TD
  subgraph Runtime layer
    Ext[3rd-party app] -- signed webhook --> GS[G-Stack /webhook]
    GS -- normalized envelope --> Her[Hermes :4100]
    Her --> CoS[Chief of Staff<br/>skill-hooks.yaml]
    CoS -->|strategy| CEO
    CoS -->|technical| CTO
    CoS --> Lib[Librarian]
    CoS --> Int[Interpreter]
    CoS --> Dis[Dispatcher]
    CoS --> Sen[Sentinel]
    Dis --> PB[Paperclip Bridge<br/>:3100/api/tasks]
    Lib --> EW[NVIDIA Embed Worker]
    EW --> VI[(tenant vector-index.jsonl)]
    Her -- audit --> Log[(_audit/hermes-events.jsonl)]
    Log --> SS[Sentinel Sweep<br/>daily]
    SS --> Evo[(agents/*/EVOLUTION.md)]
  end

  subgraph Catalog and adapter layer
    Cards[catalog/cards/*.md] --> BC[build_catalog.py]
    Agents[agents/*/MANIFEST.yaml] --> BC
    Ad[adapters/*/adapter.yaml] --> BC
    BC --> MJ[(catalog/modules.json)]
    MJ --> Prompt[onboard.py --prompt runtime]
    Prompt --> Harvest[snowgloves-harvest.md]
    Harvest --> Apply[onboard.py --apply-harvest]
    Apply --> En[(tenants/slug/enabled.yaml<br/>runtime.yaml)]
    En --> Render[onboard.py --render-adapter]
    Ad --> Render
    Render --> RT[Runtime files:<br/>skills, MCP config, rules block]
    MJ --> Dash[Tauri dashboard + Pages site]
    En --> Gate[connector-gate]
  end

Four reusable engines

EngineOwnsFiles
ConnectorG-Stack fabric, scopes, webhooksconnectors/g-stack/, skills/connector-gate/
KnowledgeIngest, chunk, embed, retrievescripts/ingest.py, scripts/embed_worker.py
InterpretationSkill routing, escalationworkflows/skill-hooks.yaml, agents/chief-of-staff/
OrchestrationEvent bus + Paperclip bridgescripts/hermes.py, scripts/paperclip_bridge.py

More detail: docs/architecture-overview.md.

<!-- readme-gen:end:architecture -->

🗂 Modules & Catalog

catalog/cards/<id>.md holds one card per third-party skill, MCP server, plugin, connector, or playbook. A card is a pointer, never a copy of upstream code. scripts/build_catalog.py compiles the cards, the agent manifests, the adapters, and the G-Stack connectors into catalog/registry.yaml and catalog/modules.json (schema snowgloves.modules.v1). Run make catalog-check locally to reject stale generated output.

DispositionCountMeaning
add61Tenant can enable it
pointer45Tenant can enable it as a reference; the host already provides it
hold14Visible, not enableable until the founder picks it and it passes review
refuse12Visible, never enableable

By category: skills 79, playbook 36, plugin 13, mcp 3, connector 1. Optional packs inference-sh-agent-skills and explee-skills are add cards, not core hooks. Card schema: docs/catalog.md. Harvest notes: docs/research/2026-09-29-ecosystem-review.md.

🔀 Runtime Adapters & Onboarding

Snow Gloves does not assume an agent. Each runtime has adapters/<runtime>/adapter.yaml (schema snowgloves.adapter.v1): skill, MCP, and rules paths; file formats; the runtime's question tool; and how to enter plan mode. Shipped adapters: hermes, claude, codex, cursor, opencode, grok, openclaw, muse, generic. Fields nobody has confirmed against the real runtime are marked verify: true and reported on every render. The Muse adapter is a guess throughout.

Onboarding is a plan-mode interview run inside the project the founder has been building:

make onboard-prompt R=cursor                                        # 1. interview → snowgloves-harvest.md
python3 scripts/onboard.py --apply-harvest snowgloves-harvest.md --tenant acme   # 2. apply
python3 scripts/onboard.py --list --category skills                 # 3. adjust
python3 scripts/onboard.py --enable ms-copywriting --tenant acme
python3 scripts/onboard.py --render-adapter cursor --tenant acme    # 4. render (dry run)
python3 scripts/onboard.py --render-adapter cursor --tenant acme --write

The agent asks one decision at a time, takes options only from modules.json, and writes FILL: instead of guessing. See docs/onboarding.md and docs/adapters.md.

🖥 Dashboard

The native Tauri v2 app in apps/onboarding/ gains a Modules dashboard and a guided onboarding flow. Both read the same catalog/modules.json plus each tenant's enabled.yaml. The same data is published as a static site, built with make site (npm run build:site) previously published by GitHub Pages. Automatic publishing is retired; this URL retains its last published content until a separate local publisher is selected:

https://sheshiyer.github.io/snow-gloves-os/

make app-install   # one-time: npm install
make app-dev       # launch in dev mode (hot-reload frontend)
make app-build     # produce a signable release bundle
make site          # build the static Pages site locally

See its README for the IPC surface and capability list.

📦 Release & Upgrade

The root VERSION file (currently 0.2.1) is the platform version. The app's package.json, tauri.conf.json, Cargo.toml, both lockfiles, and distribution.yaml are kept in lockstep with it, and catalog/modules.json embeds it.

make release-dry V=0.2.1    # show every file that would change
make release V=0.2.1        # bump, rebuild catalog, finalize CHANGELOG, commit, tag v0.2.1
make release-push V=0.2.1   # push only; no automatic signing or publication
make upgrade                # dry-run tenant migrations to VERSION
make upgrade T=acme WRITE=1 # apply, rebuild catalog, re-render acme's adapters

The retired hosted release built signed Tauri installers and attached source, catalog, adapter, and checksum assets. Those publication jobs are no longer active. make app-build remains the local app build entry point; signing, cross-platform builds, updater latest.json, and publication require a separately reviewed local procedure. v0.2.0 is a reinstall for 0.1.x (new bundle id). See docs/RELEASING.md and docs/UPGRADING.md.

<!-- readme-gen:start:tree -->

📂 Project Structure

📦 snow-gloves-os
├── 📄 VERSION                  # platform version (0.2.1)
├── 📄 distribution.yaml        # what the platform owns vs what tenants own
├── 📂 .local-jobs/             # explicit local checks; schedules disabled
├── 📂 .specify/                # Spec-Kit templates + workflows
├── 📂 .planning/               # GSD state: local symlink into the private data checkout (gitignored)
├── 📂 agents/                  # 7 agents, each with 8+ md files + MANIFEST
├── 📂 adapters/                # one adapter.yaml per runtime (9)
├── 📂 catalog/                 # SCHEMA.md · cards/ (132) · registry.yaml · modules.json (generated)
├── 📂 prompts/                 # onboard-interview.md (plan-mode interview template)
├── 📂 migrations/              # v0_1_to_v0_2.py (bus rename to Hermes)
├── 📂 apps/onboarding/         # Tauri v2 app: onboarding + Modules dashboard, Pages site
├── 📂 connectors/g-stack/      # Capability registry + auth + webhooks
├── 📂 scripts/                 # hermes · ingest · embed · bridge · sentinel · build_catalog · onboard · release · upgrade
├── 📂 skills/                  # registry.yaml (4 native skills) · connector-gate · sg-onboard · tn-seed · gtm-brief-synthesis
├── 📂 workflows/               # skill-hooks.yaml — Chief of Staff graph
├── 📂 specs/                   # Spec-Kit features
├── 📂 docs/                    # architecture, catalog, adapters, onboarding, releasing, upgrading, research, runbooks
├── 📂 tenants/                 # Fixture tenants only; real tenants live in $SNOWGLOVES_DATA/tenants
├── 📂 nodes/                   # Wing profile templates (marketing, design, coding)
├── 📄 fleet.example.yaml       # Fleet inventory template (the real fleet.yaml is private)
├── 📂 tests/                   # pytest suite
├── 📂 _audit/                  # Append-only event log
├── 📄 config/snowgloves.yaml   # Runtime config (ports, embeddings, scopes)
└── 📄 Makefile                 # make smoke runs the whole loop

<!-- readme-gen:end:tree -->

<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12,13,14&height=2" width="100%" />

<!-- readme-gen:start:health -->

📊 Project Health

CategoryStatusScore
Spec coverage████████████████████100%
Agents wired████████████████████100%
End-to-end smoke████████████████████100%
Local validation (pytest, version + catalog, make walk, smoke; execution is opt-in)████████████████████100%
Catalog (132 cards, --check in CI)████████████████████100%
Runtime adapters (9 shipped; several fields still verify: true)██████████░░░░░░░░░░50%
Release + upgrade tooling (v0.2.1 stable)████████████████░░░░80%
Real NVIDIA NIM integration████░░░░░░░░░░░░░░░░20%
Live Paperclip wiring████░░░░░░░░░░░░░░░░20%
Production hardening████░░░░░░░░░░░░░░░░20%

Overall: 70% — Stable v0.2.1 platform. Before a live pilot: confirm unverified adapter fields, and wire real connectors, NIM, and Paperclip.

<!-- readme-gen:end:health -->

Open items

  • Bundle id (decided). The app id is com.tryambakam.snowgloves.onboarding (was com.thoughtseed.snowgloves.onboarding). v0.2.0 is a new app: existing 0.1.x users must reinstall from the DMG/MSI; OTA will not find 0.2.0.
  • distribution.yaml lacks hermes_requires. Nothing declares which Hermes version or port a distribution needs yet. Do not set it in this release.
  • Unverified adapter fields. Muse is a guess throughout; the OpenClaw question tool and plan mode, and Codex /plan, are unconfirmed. See docs/adapters.md.

🧪 Spec-Driven Development

This repo uses GitHub Spec-Kit. Available slash commands (in Copilot/Codex chat):

/speckit.constitution    /speckit.specify    /speckit.clarify
/speckit.plan            /speckit.tasks      /speckit.analyze
/speckit.checklist       /speckit.implement  /speckit.taskstoissues

Active feature: specs/001-hand-in-glove-platform.

📜 Constitution

7 principles govern every change:

  1. Spec before code
  2. Tenant isolation first
  3. Interpretation before automation
  4. Approval-gated risk
  5. Auditability by default
  6. Wiki as human control surface
  7. Portable domain packs

See .specify/memory/constitution.md.

<!-- readme-gen:start:footer -->

<img src="https://capsule-render.vercel.app/api?type=waving&color=gradient&customColorList=12,13,14&height=110&section=footer" width="100%" />

Built with ❤️ by Mage Narayan · Tryambakam Noesis

<!-- readme-gen:end:footer -->

Source 3 files
hooks/register.tsx 205 lines
1// sg-hermes: the Hermes console. /hermes opens a pane with three tabs: the live event stream, a route
2// lens (type a task title, see which agent and hook the current skill-hooks.yaml picks), and a replay of
3// recent events through today's hooks. With publishTurns on, a finished turn posts a claude.turn event to
4// Hermes /publish, which gives sentinel_sweep.py real traffic to measure.
5// $.http.fetch only goes to the Hermes endpoint (option, else the platform snapshot's, else :4100).
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, PluginOptions, Register } from 'claude-code'
8
9import type { HermesEvent } from '../types'
10import { eventLine, eventsFrom, hermesUrl, mergeEvents, newestTs, platformRootFrom, replayFrom, replayLine, routeLine, routesFrom, turnEvent } from './lib'
11
12const PANE = 'sg-hermes'
13const POLL_MS = 5_000
14const RUN_TIMEOUT_MS = 20_000
15const FETCH_MS = 4_000
16
17const events = atom({ plugin: 'sg-hermes', key: 'events' } as const, [])
18const tab = atom({ plugin: 'sg-hermes', key: 'tab' } as const, 'live')
19const routes = atom({ plugin: 'sg-hermes', key: 'routes' } as const, null)
20const routedTitle = atom({ plugin: 'sg-hermes', key: 'routedTitle' } as const, '')
21const replay = atom({ plugin: 'sg-hermes', key: 'replay' } as const, null)
22const error = atom({ plugin: 'sg-hermes', key: 'error' } as const, null)
23const up = atom({ plugin: 'sg-hermes', key: 'up' } as const, null)
24
25let pluginOptions: PluginOptions = {}
26let baseUrl = ''
27let publishTurns = false
28
29async function settingsOf($: EngineInterface) {
30  const root = String(pluginOptions.platformRoot || '') || platformRootFrom($.plugin.root)
31  const dataRoot = String(pluginOptions.dataRoot || '') || (await $.env.get('SNOWGLOVES_DATA')) || ''
32  const venv = root + '/.venv/bin/python'
33  const python = (await $.fs.exists(venv)) ? venv : 'python3'
34  return { root, dataRoot, python }
35}
36
37async function resolveUrl($: EngineInterface) {
38  const option = String(pluginOptions.hermesUrl || '')
39  if (option) {
40    baseUrl = hermesUrl(option, null)
41    return
42  }
43  let out: string | null = null
44  try {
45    const cfg = await settingsOf($)
46    const argv = [cfg.python, cfg.root + '/scripts/sg_mods.py', 'snapshot', '--cwd', await $.session.cwd()]
47    if (cfg.dataRoot) argv.push('--data-root', cfg.dataRoot)
48    out = (await $.process.run(argv, { cwd: cfg.root, timeoutMs: RUN_TIMEOUT_MS })).stdout
49  } catch {
50    out = null
51  }
52  baseUrl = hermesUrl('', out)
53}
54
55// A fetch that gives up after FETCH_MS rather than hanging a timer tick
56async function call($: EngineInterface, path: string, init?: { method: string; headers: Record<string, string>; body: string }) {
57  const answered = $.http.fetch(baseUrl + path, init)
58  const timedOut = $.clock.sleep(FETCH_MS).then(() => null)
59  return Promise.race([answered, timedOut])
60}
61
62// Never rejects: it runs from a timer
63async function poll($: EngineInterface) {
64  try {
65    if (!baseUrl) await resolveUrl($)
66    const held: HermesEvent[] = await read($, events)
67    const since = newestTs(held)
68    const res = await call($, '/events' + (since ? '?since=' + encodeURIComponent(since) : ''))
69    const list = res && res.ok ? eventsFrom(res.text) : null
70    if (list === null) {
71      await update($, up, () => false)
72      await update($, error, () => (res ? 'Hermes answered ' + res.status : 'Hermes did not answer at ' + baseUrl))
73      return
74    }
75    await update($, up, () => true)
76    await update($, error, () => null)
77    if (list.length > 0) await update($, events, (old: HermesEvent[]) => mergeEvents(old, list))
78  } catch (err) {
79    const reason = String(err instanceof Error ? err.message : err)
80    await update($, up, () => false).catch(() => undefined)
81    await update($, error, () => reason).catch(() => undefined)
82  }
83}
84
85async function route($: EngineInterface, title: string) {
86  const text = title.trim()
87  if (!text) return
88  await update($, routedTitle, () => text)
89  try {
90    if (!baseUrl) await resolveUrl($)
91    const res = await call($, '/test/e2e', {
92      method: 'POST',
93      headers: { 'Content-Type': 'application/json' },
94      body: JSON.stringify({ task: { title: text, tags: [], brief: text } }),
95    })
96    const list = res && res.ok ? routesFrom(res.text) : null
97    if (list === null) {
98      await update($, error, () => 'Route test failed' + (res ? ' (http ' + res.status + ')' : ': Hermes did not answer'))
99      return
100    }
101    await update($, routes, () => list)
102    await update($, error, () => null)
103  } catch (err) {
104    await update($, error, () => String(err instanceof Error ? err.message : err))
105  }
106}
107
108async function openReplay($: EngineInterface) {
109  await update($, tab, () => 'replay')
110  await runReplay($)
111}
112
113async function runReplay($: EngineInterface) {
114  try {
115    const cfg = await settingsOf($)
116    const run = await $.process.run([cfg.python, cfg.root + '/scripts/replay.py', '--last', '20'], {
117      cwd: cfg.root + '/scripts',
118      timeoutMs: RUN_TIMEOUT_MS,
119    })
120    const rows = replayFrom(run.stdout)
121    if (rows === null) {
122      await update($, error, () => run.stderr.trim().split('\n').pop() || 'replay exit ' + run.exitCode)
123      return
124    }
125    await update($, replay, () => rows)
126    await update($, error, () => null)
127  } catch (err) {
128    await update($, error, () => String(err instanceof Error ? err.message : err))
129  }
130}
131
132export const register: Register = (on, options) => {
133  pluginOptions = options
134  publishTurns = options.publishTurns === true
135
136  on('session.start', async ($, e, next) => {
137    $.clock.every(POLL_MS, () => poll($))
138    void poll($)
139    await $.command.register({ name: 'hermes', description: 'Hermes events, route lens and replay', immediate: true })
140    return next(e)
141  })
142
143  on('command.run', { command: 'hermes' }, async ($) => {
144    await poll($)
145    const drawn = (await $.session.surfaces()).length > 0
146    const placed = drawn ? await $.ui.open({ id: PANE, title: 'Hermes', focus: true, closeOnEscape: true }) : null
147    if (placed && placed.isPlaced) return {}
148    const held: HermesEvent[] = await read($, events)
149    const reason = await read($, error)
150    if (reason !== null) return { text: 'Hermes: ' + reason }
151    return { text: held.length ? held.slice(-20).map(eventLine).join('\n') : 'No Hermes events yet.' }
152  })
153
154  // Posting never delays or changes the turn: a failed publish is dropped
155  on('turn.complete', async ($, e, next) => {
156    if (publishTurns && !e.agentId) {
157      const body = turnEvent(await $.session.id(), e.durationMs, e.usage, String((await $.env.get('SNOWGLOVES_TENANT')) || ''))
158      void call($, '/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).catch(() => undefined)
159    }
160    return next(e)
161  })
162
163  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
164    const { Box, Button, Input, Text } = $.ui.resolve(e)
165    const current = await read($, tab)
166    const reason = await read($, error)
167    const isUp = await read($, up)
168    const list: HermesEvent[] = await read($, events)
169    const title = await read($, routedTitle)
170    const found = await read($, routes)
171    const rows = await read($, replay)
172    const changed = (rows || []).filter((r) => r.changed)
173    return (
174      <Box flexDirection="column">
175        <Box flexDirection="row" columnGap={2}>
176          <Text bold>{'Hermes ' + (isUp === null ? '…' : isUp ? '●' : '○')}</Text>
177          <Button key="tab-live" label="live" hotkey="1" plain onPress={() => update($, tab, () => 'live')} />
178          <Button key="tab-route" label="route" hotkey="2" plain onPress={() => update($, tab, () => 'route')} />
179          <Button key="tab-replay" label="replay" hotkey="3" plain onPress={() => openReplay($)} />
180        </Box>
181        {reason !== null && <Text color="red">{reason}</Text>}
182        {current === 'live' && <Text dimColor>{list.length + ' events'}</Text>}
183        {current === 'live' &&
184          list.slice(-30).reverse().map((ev) => (
185            <Text key={'ev-' + ev.ts + ev.kind} wrap="truncate-end">{eventLine(ev)}</Text>
186          ))}
187        {current === 'route' && (
188          <Input key="route-title" label="task title" placeholder="launch a referral program" value={title} submitLabel="route" onSubmit={(v) => route($, v)} />
189        )}
190        {current === 'route' && found !== null && found.length === 0 && <Text dimColor>No hook matches this title.</Text>}
191        {current === 'route' &&
192          (found || []).map((r, i) => (
193            <Text key={'route-' + i} wrap="truncate-end">{routeLine(r)}</Text>
194          ))}
195        {current === 'replay' && rows === null && <Text dimColor>Running replay…</Text>}
196        {current === 'replay' && rows !== null && <Text dimColor>{rows.length + ' replayed, ' + changed.length + ' would route differently'}</Text>}
197        {current === 'replay' &&
198          changed.map((r) => (
199            <Text key={'replay-' + r.ts} wrap="truncate-end">{replayLine(r)}</Text>
200          ))}
201      </Box>
202    )
203  })
204}
205
hooks/lib.ts 85 lines
1// Pure helpers for sg-hermes. No $ here.
2import type { HermesEvent, Replay, Route } from '../types'
3
4export const DEFAULT_URL = 'http://127.0.0.1:4100'
5export const KEEP = 200
6
7export function platformRootFrom(pluginRoot: string): string {
8  return pluginRoot.split('/').slice(0, -2).join('/')
9}
10
11export function trimUrl(url: string): string {
12  return url.replace(/\/+$/, '')
13}
14
15// The Hermes endpoint: the option, else the platform snapshot's, else the default.
16export function hermesUrl(option: string, snapshotStdout: string | null): string {
17  if (option.trim()) return trimUrl(option.trim())
18  try {
19    const url = JSON.parse(snapshotStdout || '{}')?.endpoints?.hermes
20    if (typeof url === 'string' && /^https?:\/\//.test(url)) return trimUrl(url)
21  } catch {
22    // fall through to the default
23  }
24  return DEFAULT_URL
25}
26
27export function eventsFrom(text: string): HermesEvent[] | null {
28  try {
29    const data = JSON.parse(text)
30    if (!data || !Array.isArray(data.events)) return null
31    return data.events.filter((x: unknown) => x && typeof (x as HermesEvent).ts === 'string')
32  } catch {
33    return null
34  }
35}
36
37// Newest KEEP events, oldest first, no repeats of a timestamp already held.
38export function mergeEvents(old: HermesEvent[], fresh: HermesEvent[]): HermesEvent[] {
39  const seen = new Set(old.map((e) => e.ts + e.kind))
40  const added = fresh.filter((e) => !seen.has(e.ts + e.kind))
41  return [...old, ...added].slice(-KEEP)
42}
43
44export function newestTs(events: HermesEvent[]): string | null {
45  return events.length ? events[events.length - 1].ts : null
46}
47
48export function routesFrom(text: string): Route[] | null {
49  try {
50    const data = JSON.parse(text)
51    return Array.isArray(data?.routing) ? data.routing : null
52  } catch {
53    return null
54  }
55}
56
57export function replayFrom(text: string): Replay[] | null {
58  try {
59    const data = JSON.parse(text)
60    return Array.isArray(data) ? data : null
61  } catch {
62    return null
63  }
64}
65
66export function routeLine(r: Route): string {
67  return r.agent + ' via ' + r.hook + ' (' + r.matched_glob + ')' + (r.skills.length ? ' skills: ' + r.skills.join(', ') : '')
68}
69
70export function eventLine(e: HermesEvent): string {
71  const task = (e.task || (e.event as { payload?: unknown } | undefined)?.payload || {}) as { title?: string }
72  const routing = Array.isArray(e.routing) ? (e.routing as Route[]).map((r) => r.agent).join('+') : ''
73  const detail = e.kind === 'claude.turn' || (e.event as { kind?: string } | undefined)?.kind === 'claude.turn' ? 'claude turn' : task.title || ''
74  return e.ts.slice(11, 19) + '  ' + e.kind + (detail ? '  ' + detail : '') + (routing ? '  -> ' + routing : '')
75}
76
77export function replayLine(r: Replay): string {
78  const names = (rs: Route[]) => rs.map((x) => x.agent).join('+') || 'none'
79  return r.ts.slice(11, 19) + '  ' + (r.task?.title || '(untitled)') + '  ' + names(r.before) + ' -> ' + names(r.after)
80}
81
82export function turnEvent(session: string, durationMs: number, usage: unknown, tenant: string) {
83  return { channel: 'claude', event: { kind: 'claude.turn', session, durationMs, usage: usage ?? null, tenant } }
84}
85
types/index.d.ts 21 lines
1// One Hermes log record as GET /events lists it. Hermes redacts the payload before it logs it.
2export type HermesEvent = { ts: string; kind: string; [key: string]: unknown }
3// One route of a task title: scripts/hermes.py route()
4export type Route = { agent: string; hook: string; skills: string[]; matched_glob: string }
5// One line of scripts/replay.py: the route before and after the current skill-hooks.yaml
6export type Replay = { ts: string; task: { title?: string }; before: Route[]; after: Route[]; changed: boolean }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'sg-hermes': {
11      events: HermesEvent[]
12      tab: 'live' | 'route' | 'replay'
13      routes: Route[] | null
14      routedTitle: string
15      replay: Replay[] | null
16      error: string | null
17      up: boolean | null
18    }
19  }
20}
21