Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ OpenCode reads and writes this shared config file directly. OpenCode-specific de
"workspace": "opencode",
"aiPeer": "opencode",
"recallMode": "hybrid",
"sessionStrategy": "per-directory"
"sessionStrategy": "per-directory",
"removeUserPrefix": true // true uses the bare peerName; false (default on upgrade) keeps the legacy user-<peerName> peer
}
}
}
Expand Down
64 changes: 59 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ type HonchoSettings = {
workspace: string
recallMode: RecallMode
sessionStrategy: SessionStrategy
removeUserPrefix: boolean
}

type HostScopedSettings = Partial<Pick<HonchoSettings, "workspace" | "aiPeer" | "recallMode" | "sessionStrategy">>
type HostScopedSettings = Partial<
Pick<HonchoSettings, "workspace" | "aiPeer" | "recallMode" | "sessionStrategy" | "removeUserPrefix">
>

type RuntimeHandle = {
rootDir: string
Expand Down Expand Up @@ -108,6 +111,10 @@ const DEFAULT_SETTINGS: HonchoSettings = {
workspace: "opencode",
recallMode: "hybrid",
sessionStrategy: "per-directory",
// Default false for everyone, including upgrading installs: an existing user
// keeps their `user-<peerName>` peer and its memory. New installs are stamped
// true at config creation, and anyone can opt in by setting it true.
removeUserPrefix: false,
}

const INTERNAL_DIALECTIC_REASONING_LEVEL: DialecticReasoningLevel = "low"
Expand All @@ -121,7 +128,7 @@ const INTERNAL_CONTEXT_REFRESH: ContextRefreshSettings = {
useSessionStartDialectic: true,
}

const BOOLEAN_KEYS = new Set<keyof HonchoSettings>([])
const BOOLEAN_KEYS = new Set<keyof HonchoSettings>(["removeUserPrefix"])

const NUMBER_KEYS = new Set<keyof HonchoSettings>([])

Expand All @@ -133,7 +140,13 @@ const ENUM_KEYS: Record<string, ReadonlySet<string>> = {
const INHERITABLE_STRING_KEYS = new Set<keyof HonchoSettings>(["apiKey", "baseUrl", "peerName", "aiPeer", "workspace"])

const TOP_LEVEL_SETTING_FIELDS = new Set<keyof HonchoSettings>(["apiKey", "baseUrl", "peerName"])
const HOST_SETTING_FIELDS = new Set<keyof HonchoSettings>(["workspace", "aiPeer", "recallMode", "sessionStrategy"])
const HOST_SETTING_FIELDS = new Set<keyof HonchoSettings>([
"workspace",
"aiPeer",
"recallMode",
"sessionStrategy",
"removeUserPrefix",
])

const SETTING_FIELD_PATHS = new Set([
"apiKey",
Expand All @@ -143,6 +156,7 @@ const SETTING_FIELD_PATHS = new Set([
"workspace",
"recallMode",
"sessionStrategy",
"removeUserPrefix",
])

const DURABLE_PATTERNS = [
Expand Down Expand Up @@ -355,6 +369,14 @@ const applyRawLayer = (target: HonchoSettings, raw: Record<string, unknown>) =>
if (value === undefined || value === null) {
continue
}
if (BOOLEAN_KEYS.has(key)) {
// Coerce booleans at the read boundary: a config (or hand-edit, which the
// README invites) may carry the string "true"/"false". Only true/"true" is
// true, so a stray "false" can't be truthy and silently flip the prefix.
;(target as Record<string, unknown>)[key] =
value === true || (typeof value === "string" && value.trim().toLowerCase() === "true")
continue
}
if (typeof value === "string") {
const expanded = expandEnv(value)
if (INHERITABLE_STRING_KEYS.has(key) && !expanded.trim()) {
Expand Down Expand Up @@ -616,6 +638,22 @@ const writeSettings = async (

const currentUserName = () => "user"

const deriveUserPeerId = (settings: Pick<HonchoSettings, "peerName" | "removeUserPrefix">) => {
const name = settings.peerName || currentUserName()
// removeUserPrefix=true drops the `user-` prefix to match the sibling
// claude-honcho / hermes-honcho plugins; false (the legacy-safe default)
// keeps the historical `user-<name>` peer and its accumulated memory.
return settings.removeUserPrefix ? normalizeId(name) : normalizeId(`user:${name}`)
}

const assertDistinctUserAndAgentPeers = (userPeerId: string, rootAgentPeerId: string) => {
if (userPeerId === rootAgentPeerId) {
throw new Error(
`Invalid Honcho config: peerName and aiPeer both resolve to the peer id '${userPeerId}'; they must differ so user and agent memory stay separate.`,
)
}
}

const rootApiKey = (raw: Record<string, unknown>) => {
const legacyApiKey = typeof raw[LEGACY_API_KEY_FIELD] === "string" ? expandEnv(raw[LEGACY_API_KEY_FIELD] as string) : ""
return legacyApiKey
Expand All @@ -629,6 +667,7 @@ const hostDefaults = (settings: HonchoSettings): Record<string, unknown> => {
aiPeer,
recallMode: settings.recallMode,
sessionStrategy: settings.sessionStrategy,
removeUserPrefix: settings.removeUserPrefix,
}
}

Expand All @@ -653,11 +692,15 @@ const ensureSharedGlobalSettings = async (configPath = sharedGlobalSettingsPath(
if (sharedRaw) {
next = currentShared
} else {
// Brand-new install (no prior config): ship removeUserPrefix=true so new
// users get the bare `<peerName>` peer. Existing configs take the `if` branch
// untouched and fall back to the false default, preserving their `user-<name>`
// peer.
next = {
peerName: currentUserName(),
baseUrl: mergedHostSettings.baseUrl,
hosts: {
opencode: hostDefaults(mergedHostSettings),
opencode: { ...hostDefaults(mergedHostSettings), removeUserPrefix: true },
},
}
await writeSharedGlobalSettings(configPath, next)
Expand Down Expand Up @@ -754,8 +797,16 @@ const deriveRuntimeHandle = async (
const sessionId = extractSessionId(input)
const repoName = path.basename(rootDir)
const workspaceId = normalizeId(settings.workspace || "opencode")
const userPeerId = normalizeId(`user:${settings.peerName || currentUserName()}`)
const rootAgentPeerId = normalizeId(settings.aiPeer || "opencode")
// If the bare form collides with the agent peer (peerName === aiPeer), fall back
// to the prefixed form to keep user and agent memory distinct, rather than
// throwing on this hot path (deriveRuntimeHandle runs in unguarded hooks).
// assertDistinct only fires for a genuinely unresolvable config.
let userPeerId = deriveUserPeerId(settings)
if (userPeerId === rootAgentPeerId) {
userPeerId = normalizeId(`user:${settings.peerName || currentUserName()}`)
}
assertDistinctUserAndAgentPeers(userPeerId, rootAgentPeerId)
const activeAgentPeerId = rootAgentPeerId
const childAgentPeerId = null
const parentAgentObserverPeerId = null
Expand Down Expand Up @@ -1062,6 +1113,7 @@ export const createHonchoRuntimePlugin =
recallMode: handle.config.recallMode,
sessionStrategy: handle.config.sessionStrategy,
peerName: handle.config.peerName,
removeUserPrefix: handle.config.removeUserPrefix,
configured: hasConfiguredAuth(handle.config),
localMode: isLocalBaseUrl(handle.config.baseUrl),
baseUrl: handle.config.baseUrl,
Expand Down Expand Up @@ -1667,6 +1719,8 @@ export const createHonchoRuntimePlugin =
export const HonchoRuntimePlugin = createHonchoRuntimePlugin()
export const __testing = {
createSessionState,
deriveUserPeerId,
assertDistinctUserAndAgentPeers,
deriveSessionStateKey,
extractCompletedAssistantMessage,
honchoSdkImportPath: "@honcho-ai/sdk",
Expand Down
6 changes: 3 additions & 3 deletions tests/context-injection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ const createHonchoFetch = ({ failStableHydration = false } = {}) => {
return jsonResponse({
peer_id: peerId,
target_id: null,
representation: peerId.startsWith("user-")
? "The user prefers concise engineering analysis."
: "The assistant is working on opencode-honcho.",
representation: peerId === "opencode"
? "The assistant is working on opencode-honcho."
: "The user prefers concise engineering analysis.",
peer_card: ["Keep changes narrowly scoped."],
})
}
Expand Down
75 changes: 75 additions & 0 deletions tests/honcho-setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ test("honcho_setup writes shared Honcho config with root peerName and hosts.open
workspace: "opencode",
recallMode: "hybrid",
sessionStrategy: "per-directory",
removeUserPrefix: true,
})
expect("linkedHosts" in persisted.hosts.opencode).toBe(false)
expect(result.persistedFields).toContain("peerName")
Expand Down Expand Up @@ -668,3 +669,77 @@ test("honcho_status uses the project worktree to derive per-directory session ke
expect(result.sessionKey).toBe("per-directory-opencode-services-api-opencode")
})
})

test("fresh install ships removeUserPrefix=true and drops the user- prefix", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "honcho-fresh-prefix-root-"))
const homeDir = await mkdtemp(path.join(os.tmpdir(), "honcho-fresh-prefix-home-"))
const sharedConfigPath = path.join(homeDir, ".honcho", "config.json")

await withEnv(
{ HOME: homeDir, USER: "ignored-user", XDG_CONFIG_HOME: undefined, HONCHO_PEER_NAME: "alice" },
async () => {
const hooks = await createPluginHarness(rootDir)
const result = JSON.parse(await hooks.tool.honcho_status.execute({}, toolContext(rootDir)))
const persisted = JSON.parse(await readFile(sharedConfigPath, "utf-8"))

expect(result.removeUserPrefix).toBe(true)
expect(result.peers.userPeer.id).toBe("alice")
expect(persisted.hosts.opencode.removeUserPrefix).toBe(true)
},
)
})

test("upgrading install (existing config) keeps removeUserPrefix=false and the user- prefix", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "honcho-legacy-prefix-root-"))
const homeDir = await mkdtemp(path.join(os.tmpdir(), "honcho-legacy-prefix-home-"))
const sharedConfigDir = path.join(homeDir, ".honcho")
const sharedConfigPath = path.join(sharedConfigDir, "config.json")

// Config predates removeUserPrefix — the false default must apply and the file
// must not be mutated on a plain status read.
await mkdir(sharedConfigDir, { recursive: true })
const initialConfig = {
peerName: "alice",
apiKey: "legacy-key",
baseUrl: "https://api.honcho.dev",
hosts: { opencode: { aiPeer: "opencode", workspace: "opencode" } },
}
await writeFile(sharedConfigPath, JSON.stringify(initialConfig, null, 2))

await withEnv({ HOME: homeDir, USER: "ignored-user", XDG_CONFIG_HOME: undefined }, async () => {
const hooks = await createPluginHarness(rootDir)
const result = JSON.parse(await hooks.tool.honcho_status.execute({}, toolContext(rootDir)))
const persisted = JSON.parse(await readFile(sharedConfigPath, "utf-8"))

expect(result.removeUserPrefix).toBe(false)
expect(result.peers.userPeer.id).toBe("user-alice")
expect(persisted).toEqual(initialConfig)
})
})

test("a string removeUserPrefix is coerced at read time (a stray \"false\" stays on the prefixed peer)", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "honcho-coerce-root-"))
const homeDir = await mkdtemp(path.join(os.tmpdir(), "honcho-coerce-home-"))
await mkdir(path.join(homeDir, ".honcho"), { recursive: true })
const cfg = path.join(homeDir, ".honcho", "config.json")

await withEnv({ HOME: homeDir, USER: "ignored-user", XDG_CONFIG_HOME: undefined }, async () => {
await writeFile(cfg, JSON.stringify({ peerName: "alice", hosts: { opencode: { removeUserPrefix: "false" } } }))
const result = JSON.parse(await (await createPluginHarness(rootDir)).tool.honcho_status.execute({}, toolContext(rootDir)))
expect(result.peers.userPeer.id).toBe("user-alice")
})
})

test("bare peer colliding with the agent peer falls back to the prefix instead of throwing", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "honcho-collide-root-"))
const homeDir = await mkdtemp(path.join(os.tmpdir(), "honcho-collide-home-"))
await mkdir(path.join(homeDir, ".honcho"), { recursive: true })
const cfg = path.join(homeDir, ".honcho", "config.json")

await withEnv({ HOME: homeDir, USER: "ignored-user", XDG_CONFIG_HOME: undefined }, async () => {
await writeFile(cfg, JSON.stringify({ peerName: "opencode", hosts: { opencode: { aiPeer: "opencode", removeUserPrefix: true } } }))
const result = JSON.parse(await (await createPluginHarness(rootDir)).tool.honcho_status.execute({}, toolContext(rootDir)))
expect(result.ok).toBe(true)
expect(result.peers.userPeer.id).toBe("user-opencode")
})
})
13 changes: 13 additions & 0 deletions tests/peer-collision.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test"

import { __testing } from "../dist/index.js"

test("distinct user and agent peers are accepted", () => {
expect(() => __testing.assertDistinctUserAndAgentPeers("rui", "opencode")).not.toThrow()
})

test("colliding user and agent peers are rejected", () => {
expect(() => __testing.assertDistinctUserAndAgentPeers("opencode", "opencode")).toThrow(
/peerName and aiPeer both resolve to the peer id 'opencode'/,
)
})
8 changes: 4 additions & 4 deletions tests/peer-topology.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { __testing } from "../dist/index.js"
test("root sessions keep user and root agent as peers", () => {
const topology = __testing.buildPeerTopology({
config: {},
userPeerId: "user:user",
userPeerId: "user",
rootAgentPeerId: "opencode",
activeAgentPeerId: "opencode",
childAgentPeerId: null,
parentAgentObserverPeerId: null,
})

expect(topology.sessionPeerConfigs).toEqual({
"user:user": { observeMe: true, observeOthers: false },
"user": { observeMe: true, observeOthers: false },
opencode: { observeMe: true, observeOthers: true },
})
expect(topology.describedPeers.childAgentPeer).toBeNull()
Expand All @@ -23,15 +23,15 @@ test("root sessions keep user and root agent as peers", () => {
test("classic peer model keeps delegated sessions on the Claude-style user and ai peers", () => {
const topology = __testing.buildPeerTopology({
config: {},
userPeerId: "user:user",
userPeerId: "user",
rootAgentPeerId: "opencode",
activeAgentPeerId: "opencode:reviewer",
childAgentPeerId: "opencode:reviewer",
parentAgentObserverPeerId: "opencode:root-parent",
})

expect(topology.sessionPeerConfigs).toEqual({
"user:user": { observeMe: true, observeOthers: false },
"user": { observeMe: true, observeOthers: false },
opencode: { observeMe: true, observeOthers: true },
})
expect(topology.describedPeers.childAgentPeer).toBeNull()
Expand Down
12 changes: 12 additions & 0 deletions tests/user-peer-id.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test"

import { __testing } from "../dist/index.js"

test("removeUserPrefix=false (the default) keeps the legacy user- prefix", () => {
expect(__testing.deriveUserPeerId({ peerName: "rui", removeUserPrefix: false })).toBe("user-rui")
expect(__testing.deriveUserPeerId({ peerName: "rui" })).toBe("user-rui")
})

test("removeUserPrefix=true drops the prefix", () => {
expect(__testing.deriveUserPeerId({ peerName: "rui", removeUserPrefix: true })).toBe("rui")
})