feat(harness-plugin-core): simplify telemetry headers and HOME-first config path (#1124)

* feat(harness-plugin-core): simplify telemetry headers and HOME-first config path

- Identity is three headers: X-Honcho-Host `name/version (platform)`,
  X-Honcho-Plugin `name/version`, X-Honcho-Agent-Model. X-Honcho-Runtime is
  dropped. TelemetryIdentity gains `plugin` and `platform`.
- configPath() resolves env.HOME (then USERPROFILE) before os.homedir(), since
  Bun's homedir() ignores in-process HOME changes and plugin tests were hitting
  the real ~/.honcho/config.json.
- Extensionless internal imports so consumers no longer need
  allowImportingTsExtensions to type-check against the source exports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: minor nits

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ajspig 2026-09-03 17:19:15 -04:00 committed by GitHub
parent 9677f3d80c
commit 699c99368d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 100 additions and 64 deletions

View File

@ -43,11 +43,12 @@ Pass `telemetryHeaders()` as the SDK's `defaultHeaders`. Arbitrary headers are a
| Header | Meaning | Example |
|---|---|---|
| `X-Honcho-Host` | Agent host name, or `name/version` | `harness/1.3.13` |
| `X-Honcho-Plugin` | Honcho plugin version | `0.1.3` |
| `X-Honcho-Runtime` | This package's version (always sent) | `0.1.0` |
| `X-Honcho-Host` | Host harness, `name/version (platform)` | `harness/2.1.3 (darwin)` |
| `X-Honcho-Plugin` | Honcho integration, `name/version` | `harness-honcho/0.2.11` |
| `X-Honcho-Agent-Model` | The agent's completion model, not a Honcho model | `claude-sonnet-4-5` |
Omit `hostVersion` when the harness does not expose it; `platform` defaults to `process.platform`.
```ts
import { Honcho } from '@honcho-ai/sdk'
import { loadConfig, setTelemetryHeaders, telemetryHeaders } from '@honcho-ai/harness-plugin-core'
@ -61,6 +62,7 @@ const honcho = new Honcho({
defaultHeaders: telemetryHeaders({
host: 'harness',
hostVersion: '1.3.13',
plugin: 'harness-honcho',
pluginVersion: '0.1.3',
model: 'claude-sonnet-4-5',
}),

View File

@ -216,8 +216,18 @@ export function resolveConfig(
}
}
/**
* `HONCHO_CONFIG_PATH` if set, returned verbatim. Otherwise `.honcho/config.json`
* under `HOME`, then `USERPROFILE` (Windows), then `os.homedir()`.
*
* `env.HOME` is consulted before `os.homedir()` because Bun's `homedir()` ignores
* in-process changes to `process.env.HOME`, so tests that redirect HOME would
* otherwise read and write the real config file.
*/
export function configPath(env: NodeJS.Dict<string> = process.env): string {
return env.HONCHO_CONFIG_PATH || join(homedir(), '.honcho', 'config.json')
if (env.HONCHO_CONFIG_PATH) return env.HONCHO_CONFIG_PATH
const home = env.HOME || env.USERPROFILE || homedir()
return join(home, '.honcho', 'config.json')
}
export function loadConfig(opts: {

View File

@ -1,5 +1,3 @@
export const version = '0.1.0'
export {
configPath,
loadConfig,
@ -7,7 +5,7 @@ export {
resolveConfig,
DEFAULT_BASE_URL,
DEFAULT_TIMEOUT_MS,
} from './config.ts'
} from './config'
export type {
AuthConfig,
@ -15,15 +13,16 @@ export type {
HostBlock,
ResolvedConfig,
RootConfig,
} from './config.ts'
} from './config'
export {
hostHeaderValue,
pluginHeaderValue,
telemetryHeaders,
setTelemetryHeaders,
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
HEADER_RUNTIME,
} from './telemetry.ts'
} from './telemetry'
export type { TelemetryIdentity } from './telemetry.ts'
export type { TelemetryIdentity } from './telemetry'

View File

@ -1,12 +1,14 @@
import { version } from './index.ts'
/** Optional identity a host plugin knows at Honcho-client construction time. */
export interface TelemetryIdentity {
/** Host app name, e.g. `cursor`, `opencode`. */
/** Host harness name, e.g. `harness`. */
host?: string
/** Host app version, e.g. `2026.8.1`. */
/** Host harness version, e.g. `2.1.3`. Omit when the harness does not expose it. */
hostVersion?: string
/** Honcho plugin version, e.g. `0.1.2`. */
/** OS platform. Defaults to `process.platform`. */
platform?: string
/** Integration (plugin) name, e.g. `harness-honcho`. */
plugin?: string
/** Integration version, e.g. `0.2.11`. */
pluginVersion?: string
/** Agent completion model, e.g. `claude-sonnet-4-5`. Not a Honcho deriver/dialectic model. */
model?: string
@ -14,7 +16,6 @@ export interface TelemetryIdentity {
export const HEADER_HOST = 'X-Honcho-Host'
export const HEADER_PLUGIN = 'X-Honcho-Plugin'
export const HEADER_RUNTIME = 'X-Honcho-Runtime'
export const HEADER_AGENT_MODEL = 'X-Honcho-Agent-Model'
function sanitize(value: unknown): string | undefined {
@ -23,24 +24,39 @@ function sanitize(value: unknown): string | undefined {
return s || undefined
}
function hostValue(id: TelemetryIdentity): string | undefined {
const name = sanitize(id.host)
const ver = sanitize(id.hostVersion)
if (name && ver) return `${name}/${ver}`
return name || ver
/** A `name/version` product token. Characters that would break parsing become `-`. */
function token(name: unknown, ver: unknown): string | undefined {
const clean = (v: unknown) => sanitize(v)?.replace(/[\s()/;]+/g, '-')
const n = clean(name)
const v = clean(ver)
if (n && v) return `${n}/${v}`
return n || v
}
/** `X-Honcho-Host` value: `harness/2.1.3 (darwin)`. Undefined when the host is unknown. */
export function hostHeaderValue(id: TelemetryIdentity = {}): string | undefined {
const host = token(id.host, id.hostVersion)
if (!host) return undefined
const platform = token(id.platform ?? process.platform, undefined)
return platform ? `${host} (${platform})` : host
}
/** `X-Honcho-Plugin` value: `harness-honcho/0.2.11`. Undefined when the plugin is unknown. */
export function pluginHeaderValue(id: TelemetryIdentity = {}): string | undefined {
return token(id.plugin, id.pluginVersion)
}
/**
* Headers to pass as the SDK's `defaultHeaders`. Missing fields are omitted.
* `X-Honcho-Runtime` is always this package's version.
* Headers to pass as the SDK's `defaultHeaders`. Fields are omitted when unknown, so a
* partial identity (e.g. just `model`) only touches the headers it names.
*/
export function telemetryHeaders(
id: TelemetryIdentity = {},
extra?: Record<string, string>
): Record<string, string> {
const headers: Record<string, string> = { [HEADER_RUNTIME]: version }
const host = hostValue(id)
const plugin = sanitize(id.pluginVersion)
const headers: Record<string, string> = {}
const host = hostHeaderValue(id)
const plugin = pluginHeaderValue(id)
const model = sanitize(id.model)
if (host) headers[HEADER_HOST] = host
if (plugin) headers[HEADER_PLUGIN] = plugin

View File

@ -1,5 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { normalizeBaseUrl, resolveConfig } from '../src/index.ts'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { configPath, normalizeBaseUrl, resolveConfig } from '../src/index'
const emptyEnv = {}
@ -69,3 +71,11 @@ describe('resolveConfig', () => {
expect(cfg.workspace).toBe('my-host')
})
})
describe('configPath', () => {
test('HONCHO_CONFIG_PATH, then $HOME, then os.homedir()', () => {
expect(configPath({ HONCHO_CONFIG_PATH: '/x/cfg.json', HOME: '/h' })).toBe('/x/cfg.json')
expect(configPath({ HOME: '/scratch' })).toBe('/scratch/.honcho/config.json')
expect(configPath({})).toBe(join(homedir(), '.honcho', 'config.json'))
})
})

View File

@ -3,54 +3,54 @@ import {
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
HEADER_RUNTIME,
setTelemetryHeaders,
telemetryHeaders,
version,
} from '../src/index.ts'
} from '../src/index'
describe('telemetryHeaders', () => {
test('empty identity still sends the runtime version', () => {
expect(telemetryHeaders()).toEqual({ [HEADER_RUNTIME]: version })
})
test('maps identity to headers', () => {
expect(
telemetryHeaders({
host: 'opencode',
hostVersion: '1.3.13',
pluginVersion: '0.1.3',
model: 'claude-sonnet-4-5',
})
).toEqual({
[HEADER_RUNTIME]: version,
[HEADER_HOST]: 'opencode/1.3.13',
[HEADER_PLUGIN]: '0.1.3',
test('maps identity to the three headers', () => {
const headers = telemetryHeaders({
host: 'harness',
hostVersion: '2.1.3',
platform: 'darwin',
plugin: 'harness-honcho',
pluginVersion: '0.2.11',
model: 'claude-sonnet-4-5',
})
expect(headers).toEqual({
[HEADER_HOST]: 'harness/2.1.3 (darwin)',
[HEADER_PLUGIN]: 'harness-honcho/0.2.11',
[HEADER_AGENT_MODEL]: 'claude-sonnet-4-5',
})
})
test('merges extra headers last, skipping blanks', () => {
const headers = telemetryHeaders({ host: 'codex', pluginVersion: '0.1.1' }, {
'X-Custom': 'yes',
test('omits unknown fields and defaults platform', () => {
expect(telemetryHeaders()).toEqual({})
expect(telemetryHeaders({ host: 'harness' })).toEqual({
[HEADER_HOST]: `harness (${process.platform})`,
})
})
test('strips separators that would break parsing', () => {
expect(telemetryHeaders({ host: 'a b;(c)/d', hostVersion: '1\r\n2', platform: 'darwin' })).toEqual({
[HEADER_HOST]: 'a-b-c-d/1-2 (darwin)',
})
})
test('extra headers win, blanks are dropped', () => {
const headers = telemetryHeaders({ plugin: 'harness-honcho' }, {
[HEADER_PLUGIN]: 'override',
'X-Empty': ' ',
})
expect(headers[HEADER_HOST]).toBe('codex')
expect(headers[HEADER_PLUGIN]).toBe('override')
expect(headers['X-Custom']).toBe('yes')
expect(headers).not.toHaveProperty('X-Empty')
expect(headers).toEqual({ [HEADER_PLUGIN]: 'override' })
})
})
describe('setTelemetryHeaders', () => {
test('mutates an existing header map in place', () => {
const headers = telemetryHeaders({ host: 'cursor', pluginVersion: '0.1.2' })
const returned = setTelemetryHeaders(headers, { model: 'claude-opus-4' })
expect(returned).toBe(headers)
expect(headers[HEADER_HOST]).toBe('cursor')
expect(headers[HEADER_PLUGIN]).toBe('0.1.2')
expect(headers[HEADER_RUNTIME]).toBe(version)
expect(headers[HEADER_AGENT_MODEL]).toBe('claude-opus-4')
test('setTelemetryHeaders updates only the named fields in place', () => {
const headers = telemetryHeaders({ plugin: 'harness-honcho', pluginVersion: '0.1.2' })
expect(setTelemetryHeaders(headers, { model: 'claude-opus-4' })).toBe(headers)
expect(headers).toEqual({
[HEADER_PLUGIN]: 'harness-honcho/0.1.2',
[HEADER_AGENT_MODEL]: 'claude-opus-4',
})
})

View File

@ -3,7 +3,6 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,