Skip to content

Commit 31a04b3

Browse files
committed
release: v0.1.4
1 parent 91ae7e0 commit 31a04b3

10 files changed

Lines changed: 569 additions & 110 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## v0.1.4 — 2026-06-26
4+
5+
- Added editable Hermes session titles, including first-message auto-naming for new Browser sessions.
6+
- Reworked connection state so the side panel uses live gateway reachability instead of treating a saved API key as connected.
7+
- Added commit-aware update checks for unpacked builds, including same-version "unpulled commits" guidance.
8+
- Expanded agent discovery to trusted remote hosts while keeping bearer tokens off non-Hermes probe targets.
9+
- Refined the default Nous palette toward the ink-blue/soft-white Desktop look.
10+
311
## v0.1.1 — 2026-06-24
412

513
- Added drag/drop attachments directly into the composer, including PDFs and files.

extension/lib/agent-discovery.mjs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,73 @@
11
// agent-discovery.mjs
2-
// Discover local Hermes API gateways by probing /health across a configurable
3-
// localhost port range. This backs the "Connected agent" picker in the side
4-
// panel settings and is extracted from sidepanel.js for testability.
2+
// Discover Hermes API gateways by probing /health across a configurable trusted
3+
// host + port range. This backs the "Connected agent" picker in the side panel
4+
// settings and is extracted from sidepanel.js for testability.
55

66
import { normalizeGatewayUrl } from './common.mjs';
77

88
export const DEFAULT_AGENT_PORTS = Object.freeze([8642, 8643, 8644, 8645, 8646]);
99
const PROBE_TIMEOUT_MS = 1500;
1010

11+
export function normalizeAgentDiscoveryScheme(value = 'http') {
12+
return String(value || '').trim().toLowerCase() === 'https' ? 'https' : 'http';
13+
}
14+
15+
export function normalizeAgentDiscoveryHost(value = '127.0.0.1') {
16+
const raw = String(value || '').trim();
17+
if (!raw) return '127.0.0.1';
18+
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
19+
let parsed;
20+
try {
21+
parsed = new URL(candidate);
22+
} catch {
23+
throw new Error('Invalid agent discovery host. Enter a hostname or IP address only.');
24+
}
25+
if (parsed.username || parsed.password) throw new Error('Agent discovery host must not include userinfo.');
26+
const path = parsed.pathname || '/';
27+
if (path !== '/' || parsed.search || parsed.hash) throw new Error('Agent discovery host must not include a path, query, or fragment.');
28+
if (parsed.port) throw new Error('Agent discovery host must not include a port. Use Agent ports instead.');
29+
const host = parsed.hostname || '';
30+
if (!host) throw new Error('Agent discovery host is missing.');
31+
if (host.includes(':')) return `[${host.replace(/^\[|\]$/g, '')}]`;
32+
if (!/^[a-z0-9.-]+$/i.test(host)) throw new Error('Agent discovery host contains unsupported characters.');
33+
return host;
34+
}
35+
36+
async function probeGatewayModelName(baseUrl, { apiKey = '', signal } = {}) {
37+
try {
38+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
39+
const response = await fetch(`${baseUrl}/v1/models`, { headers, signal });
40+
if (!response.ok) return '';
41+
const payload = await response.json().catch(() => ({}));
42+
const first = Array.isArray(payload?.data) ? payload.data[0] : null;
43+
return String(first?.id || '').trim();
44+
} catch (_error) {
45+
return '';
46+
}
47+
}
48+
1149
export async function probeGatewayHealth(baseUrl, { apiKey = '', timeoutMs = PROBE_TIMEOUT_MS } = {}) {
1250
if (!baseUrl) return { ok: false, error: 'no-url' };
13-
const url = `${normalizeGatewayUrl(baseUrl)}/health`;
51+
const normalized = normalizeGatewayUrl(baseUrl);
52+
const url = `${normalized}/health`;
1453
const controller = new AbortController();
1554
const timer = setTimeout(() => controller.abort(), timeoutMs);
1655
try {
17-
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
18-
const response = await fetch(url, { headers, signal: controller.signal });
56+
// First probe is intentionally unauthenticated. Only send the user's bearer
57+
// token after the endpoint identifies itself as Hermes. This prevents a typo
58+
// or non-Hermes service on a trusted host from receiving the token.
59+
const response = await fetch(url, { signal: controller.signal });
1960
const body = await response.json().catch(() => ({}));
61+
const hermes = response.ok && body.platform === 'hermes-agent';
62+
let model = body.model || '';
63+
if (hermes && !model) model = await probeGatewayModelName(normalized, { apiKey, signal: controller.signal });
2064
return {
21-
ok: response.ok,
65+
ok: hermes,
2266
status: response.status,
2367
version: body.version || '',
2468
platform: body.platform || '',
25-
model: body.model || '',
69+
model,
70+
error: hermes ? '' : (response.ok ? 'not-hermes' : ''),
2671
};
2772
} catch (error) {
2873
return { ok: false, error: error?.name === 'AbortError' ? 'timeout' : (error?.message || 'error') };
@@ -34,9 +79,7 @@ export async function probeGatewayHealth(baseUrl, { apiKey = '', timeoutMs = PRO
3479
export function deriveAgentName(port, probe) {
3580
if (!probe || !probe.ok) return null;
3681
if (probe.platform !== 'hermes-agent') return null;
37-
// Future: gateway could expose its profile name in /health/detailed.
38-
// For now, port-derived name is the only reliable label.
39-
return `agent-${port}`;
82+
return probe.model || `agent-${port}`;
4083
}
4184

4285
export async function discoverLocalAgents({
@@ -46,8 +89,10 @@ export async function discoverLocalAgents({
4689
scheme = 'http',
4790
} = {}) {
4891
if (!Array.isArray(ports) || !ports.length) return [];
92+
const safeHost = normalizeAgentDiscoveryHost(host);
93+
const safeScheme = normalizeAgentDiscoveryScheme(scheme);
4994
const candidates = ports.map((port) => ({
50-
url: `${scheme}://${host}:${port}`,
95+
url: `${safeScheme}://${safeHost}:${port}`,
5196
port,
5297
}));
5398
const results = await Promise.all(

extension/lib/common.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export const DEFAULT_SETTINGS = Object.freeze({
4646
includePageText: true,
4747
includeSelectedText: true,
4848
transcriptProvider: 'default',
49+
agentDiscoveryHost: '127.0.0.1',
50+
agentDiscoveryScheme: 'http',
51+
autoNameSessions: true,
4952
colorMode: 'dark',
5053
appearanceTheme: 'nous',
5154
maxTabs: 12,
@@ -300,6 +303,67 @@ export function isNewerVersion(candidate = '0.0.0', current = '0.0.0') {
300303
return compareVersionStrings(candidate, current) > 0;
301304
}
302305

306+
export function formatUpdateStatus({ latestVersion = '0.0.0', currentVersion = '0.0.0', commitsBehind = 0 } = {}) {
307+
const latest = String(latestVersion || '').trim().replace(/^v/i, '') || '0.0.0';
308+
const current = String(currentVersion || '').trim().replace(/^v/i, '') || '0.0.0';
309+
const behind = Math.max(0, Number.parseInt(commitsBehind, 10) || 0);
310+
const versionComparison = compareVersionStrings(latest, current);
311+
const updateInstructions = 'Pull latest, run npm run build, then reload the unpacked dist/ folder.';
312+
if (versionComparison > 0) {
313+
const behindNote = behind ? ` ${behind} commit${behind === 1 ? '' : 's'} behind.` : '';
314+
return `Update available: v${latest}.${behindNote} ${updateInstructions}`.replace(/\s+/g, ' ').trim();
315+
}
316+
if (versionComparison < 0) {
317+
return `This build is ahead of main: v${current} installed, v${latest} on GitHub.`;
318+
}
319+
if (behind > 0) {
320+
return `v${current} installed, v${latest} latest — but ${behind} unpulled commit${behind === 1 ? '' : 's'}. ${updateInstructions}`;
321+
}
322+
return `You're up to date on v${current}.`;
323+
}
324+
325+
export function connectionStateForGateway({
326+
gatewayMode = DEFAULT_SETTINGS.gatewayMode,
327+
gatewayUrl = DEFAULT_SETTINGS.gatewayUrl,
328+
apiKey = '',
329+
probeStatus = 'unreachable',
330+
remoteWsReadyState = -1,
331+
} = {}) {
332+
const mode = normalizeGatewayMode(gatewayMode);
333+
const configured = mode === 'remote-dashboard'
334+
? isUsableRemoteGatewayUrl(gatewayUrl)
335+
: Boolean(apiKey) && (mode === 'local-api' || isUsableRemoteGatewayUrl(gatewayUrl));
336+
if (!configured) return { state: 'unconfigured', connected: false, pillClass: 'warn' };
337+
if (mode === 'remote-dashboard') {
338+
if (remoteWsReadyState === 1) return { state: 'connected', connected: true, pillClass: 'ok' };
339+
if (remoteWsReadyState === 0 || probeStatus === 'connecting') return { state: 'connecting', connected: false, pillClass: 'warn' };
340+
return { state: 'unreachable', connected: false, pillClass: 'error' };
341+
}
342+
if (probeStatus === 'connected') return { state: 'connected', connected: true, pillClass: 'ok' };
343+
if (probeStatus === 'connecting') return { state: 'connecting', connected: false, pillClass: 'warn' };
344+
return { state: 'unreachable', connected: false, pillClass: 'error' };
345+
}
346+
347+
export function isDefaultBrowserSessionTitle(title = '', defaultTitle = DEFAULT_SETTINGS.sessionTitle) {
348+
const value = String(title || '').trim();
349+
const base = String(defaultTitle || DEFAULT_SETTINGS.sessionTitle).trim();
350+
return value === base || value.startsWith(`${base} ·`);
351+
}
352+
353+
export function autoSessionTitleFromText(value = '', { maxChars = 58 } = {}) {
354+
const clean = String(value || '')
355+
.replace(/https?:\/\/\S+/gi, ' ')
356+
.replace(/\s+/g, ' ')
357+
.trim()
358+
.replace(/^["'`\s]+|["'`\s]+$/g, '')
359+
.replace(/[?.!,;:]+$/g, '');
360+
if (!clean) return '';
361+
const lowered = clean.replace(/^(can you|could you|please|pls|hey hermes|hermes)\s+/i, '');
362+
const sentence = lowered.split(/(?<=[.!?])\s+/)[0].replace(/[?.!,;:]+$/g, '').trim();
363+
const clipped = sentence.length > maxChars ? `${sentence.slice(0, maxChars - 1).trimEnd()}…` : sentence;
364+
return clipped ? `${clipped.charAt(0).toUpperCase()}${clipped.slice(1)}` : '';
365+
}
366+
303367
const MODEL_CONTEXT_FALLBACKS = Object.freeze([
304368
['claude-fable', 1_000_000],
305369
['claude-opus-4.8', 1_000_000],

extension/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": 3,
33
"name": "Hermes Browser Extension",
44
"short_name": "Hermes Ext",
5-
"version": "0.1.3",
5+
"version": "0.1.4",
66
"description": "Browser-native side panel for Hermes Agent — connect web context to your local or remote Hermes runtime.",
77
"minimum_chrome_version": "114",
88
"icons": {

extension/sidepanel.css

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,18 @@
6060

6161
:root {
6262
color-scheme: light dark;
63-
--hermes-blue: #0000f2;
64-
--hermes-blue-deep: #0000c8;
65-
--hermes-paper: #ffffff;
66-
--hermes-fg: #f5f5f5;
67-
--hermes-ink: #0000f2;
68-
--hermes-accent: #edff45;
69-
--hermes-bg-rgb: 0, 0, 242;
70-
--hermes-panel-rgb: 0, 0, 242;
71-
--hermes-paper-rgb: 255, 255, 255;
72-
--hermes-fg-rgb: 245, 245, 245;
73-
--hermes-ink-rgb: 0, 0, 242;
74-
--hermes-accent-rgb: 237, 255, 69;
63+
--hermes-blue: #0505e8;
64+
--hermes-blue-deep: #03038f;
65+
--hermes-paper: #fbfcff;
66+
--hermes-fg: #f8faff;
67+
--hermes-ink: #0505e8;
68+
--hermes-accent: #f8faff;
69+
--hermes-bg-rgb: 5, 5, 232;
70+
--hermes-panel-rgb: 5, 5, 232;
71+
--hermes-paper-rgb: 251, 252, 255;
72+
--hermes-fg-rgb: 248, 250, 255;
73+
--hermes-ink-rgb: 5, 5, 232;
74+
--hermes-accent-rgb: 248, 250, 255;
7575
--hermes-shadow-rgb: 0, 0, 0;
7676
--hermes-menu-bg: #102d5f;
7777
--hermes-line: rgba(var(--hermes-ink-rgb), 0.28);
@@ -110,35 +110,35 @@
110110
}
111111

112112
html[data-hermes-theme="nous"][data-hermes-mode="dark"] {
113-
--hermes-blue: #0000f2;
114-
--hermes-blue-deep: #0000c8;
115-
--hermes-paper: #ffffff;
116-
--hermes-fg: #f5f5f5;
117-
--hermes-ink: #0000f2;
118-
--hermes-accent: #edff45;
119-
--hermes-bg-rgb: 0, 0, 242;
120-
--hermes-panel-rgb: 0, 0, 242;
121-
--hermes-paper-rgb: 255, 255, 255;
122-
--hermes-fg-rgb: 245, 245, 245;
123-
--hermes-ink-rgb: 0, 0, 242;
124-
--hermes-accent-rgb: 237, 255, 69;
125-
--hermes-menu-bg: #102d5f;
113+
--hermes-blue: #0505e8;
114+
--hermes-blue-deep: #03038f;
115+
--hermes-paper: #fbfcff;
116+
--hermes-fg: #f8faff;
117+
--hermes-ink: #0505e8;
118+
--hermes-accent: #f8faff;
119+
--hermes-bg-rgb: 5, 5, 232;
120+
--hermes-panel-rgb: 5, 5, 232;
121+
--hermes-paper-rgb: 251, 252, 255;
122+
--hermes-fg-rgb: 248, 250, 255;
123+
--hermes-ink-rgb: 5, 5, 232;
124+
--hermes-accent-rgb: 248, 250, 255;
125+
--hermes-menu-bg: #06115f;
126126
}
127127

128128
html[data-hermes-theme="nous"][data-hermes-mode="light"] {
129-
--hermes-blue: #edf3ff;
130-
--hermes-blue-deep: #dbe8ff;
129+
--hermes-blue: #f4f7ff;
130+
--hermes-blue-deep: #e3ebff;
131131
--hermes-paper: #ffffff;
132-
--hermes-fg: #0000f2;
133-
--hermes-ink: #0000f2;
134-
--hermes-accent: #b9d2ff;
135-
--hermes-bg-rgb: 237, 243, 255;
132+
--hermes-fg: #03038f;
133+
--hermes-ink: #0505e8;
134+
--hermes-accent: #dbe6ff;
135+
--hermes-bg-rgb: 244, 247, 255;
136136
--hermes-panel-rgb: 255, 255, 255;
137137
--hermes-paper-rgb: 255, 255, 255;
138-
--hermes-fg-rgb: 0, 0, 242;
139-
--hermes-ink-rgb: 0, 0, 242;
140-
--hermes-accent-rgb: 185, 210, 255;
141-
--hermes-menu-bg: #f7faff;
138+
--hermes-fg-rgb: 3, 3, 143;
139+
--hermes-ink-rgb: 5, 5, 232;
140+
--hermes-accent-rgb: 219, 230, 255;
141+
--hermes-menu-bg: #f9fbff;
142142
}
143143

144144
html[data-hermes-theme="midnight"][data-hermes-mode="dark"] {

extension/sidepanel.html

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,14 +206,24 @@ <h3>Active profile</h3>
206206
<section class="settings-section">
207207
<h3>Connected agent</h3>
208208
<p class="hint">
209-
Scans localhost for running Hermes API gateways. Each agent can run
210-
as a separate gateway process on its own port. Use this to switch
211-
between trusted local agents that share the same browser token.
209+
Scans a trusted Hermes API host for running gateways. Use localhost for
210+
same-machine agents, or a Tailscale host/IP for private remote agents.
212211
</p>
213212
<div id="agentList" class="agent-list" role="list" aria-label="Discovered Hermes agents"></div>
214213
<div class="settings-row">
215-
<button id="refreshAgentsButton" class="secondary" type="button">↻ Scan local agents</button>
214+
<button id="refreshAgentsButton" class="secondary" type="button">↻ Scan agents</button>
216215
</div>
216+
<label>
217+
Agent host
218+
<input id="agentHostInput" type="text" autocomplete="off" placeholder="127.0.0.1 or macbook.tailnet.ts.net" />
219+
</label>
220+
<label>
221+
Agent scheme
222+
<select id="agentSchemeInput">
223+
<option value="http">http</option>
224+
<option value="https">https</option>
225+
</select>
226+
</label>
217227
<label>
218228
Agent ports (comma-separated, default 8642-8646)
219229
<input id="agentPortsInput" type="text" inputmode="numeric" placeholder="8642,8643,8644,8645,8646" />
@@ -294,6 +304,10 @@ <h3 id="connectionTitle">Gateway Connection</h3>
294304
<input id="sessionTitleInput" name="sessionTitle" type="text" placeholder="Hermes Browser Extension" />
295305
</label>
296306

307+
<label class="checkbox-row">
308+
<input id="autoNameSessionsInput" type="checkbox" /> Auto-name new browser sessions from the first message
309+
</label>
310+
297311
<label>
298312
Page context depth
299313
<select id="contextDepthInput" name="contextDepth">

0 commit comments

Comments
 (0)