diff --git a/README.md b/README.md index 47ab5b1..836b2ff 100644 --- a/README.md +++ b/README.md @@ -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- peer } } } diff --git a/src/index.ts b/src/index.ts index f19e669..27b0463 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,9 +26,12 @@ type HonchoSettings = { workspace: string recallMode: RecallMode sessionStrategy: SessionStrategy + removeUserPrefix: boolean } -type HostScopedSettings = Partial> +type HostScopedSettings = Partial< + Pick +> type RuntimeHandle = { rootDir: string @@ -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-` 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" @@ -121,7 +128,7 @@ const INTERNAL_CONTEXT_REFRESH: ContextRefreshSettings = { useSessionStartDialectic: true, } -const BOOLEAN_KEYS = new Set([]) +const BOOLEAN_KEYS = new Set(["removeUserPrefix"]) const NUMBER_KEYS = new Set([]) @@ -133,7 +140,13 @@ const ENUM_KEYS: Record> = { const INHERITABLE_STRING_KEYS = new Set(["apiKey", "baseUrl", "peerName", "aiPeer", "workspace"]) const TOP_LEVEL_SETTING_FIELDS = new Set(["apiKey", "baseUrl", "peerName"]) -const HOST_SETTING_FIELDS = new Set(["workspace", "aiPeer", "recallMode", "sessionStrategy"]) +const HOST_SETTING_FIELDS = new Set([ + "workspace", + "aiPeer", + "recallMode", + "sessionStrategy", + "removeUserPrefix", +]) const SETTING_FIELD_PATHS = new Set([ "apiKey", @@ -143,6 +156,7 @@ const SETTING_FIELD_PATHS = new Set([ "workspace", "recallMode", "sessionStrategy", + "removeUserPrefix", ]) const DURABLE_PATTERNS = [ @@ -355,6 +369,14 @@ const applyRawLayer = (target: HonchoSettings, raw: Record) => 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)[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()) { @@ -616,6 +638,22 @@ const writeSettings = async ( const currentUserName = () => "user" +const deriveUserPeerId = (settings: Pick) => { + 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-` 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) => { const legacyApiKey = typeof raw[LEGACY_API_KEY_FIELD] === "string" ? expandEnv(raw[LEGACY_API_KEY_FIELD] as string) : "" return legacyApiKey @@ -629,6 +667,7 @@ const hostDefaults = (settings: HonchoSettings): Record => { aiPeer, recallMode: settings.recallMode, sessionStrategy: settings.sessionStrategy, + removeUserPrefix: settings.removeUserPrefix, } } @@ -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 `` peer. Existing configs take the `if` branch + // untouched and fall back to the false default, preserving their `user-` + // peer. next = { peerName: currentUserName(), baseUrl: mergedHostSettings.baseUrl, hosts: { - opencode: hostDefaults(mergedHostSettings), + opencode: { ...hostDefaults(mergedHostSettings), removeUserPrefix: true }, }, } await writeSharedGlobalSettings(configPath, next) @@ -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 @@ -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, @@ -1667,6 +1719,8 @@ export const createHonchoRuntimePlugin = export const HonchoRuntimePlugin = createHonchoRuntimePlugin() export const __testing = { createSessionState, + deriveUserPeerId, + assertDistinctUserAndAgentPeers, deriveSessionStateKey, extractCompletedAssistantMessage, honchoSdkImportPath: "@honcho-ai/sdk", diff --git a/tests/context-injection.test.js b/tests/context-injection.test.js index c2712ae..8bfe17e 100644 --- a/tests/context-injection.test.js +++ b/tests/context-injection.test.js @@ -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."], }) } diff --git a/tests/honcho-setup.test.js b/tests/honcho-setup.test.js index ca30435..8507b76 100644 --- a/tests/honcho-setup.test.js +++ b/tests/honcho-setup.test.js @@ -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") @@ -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") + }) +}) diff --git a/tests/peer-collision.test.js b/tests/peer-collision.test.js new file mode 100644 index 0000000..6245713 --- /dev/null +++ b/tests/peer-collision.test.js @@ -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'/, + ) +}) diff --git a/tests/peer-topology.test.js b/tests/peer-topology.test.js index 02167d8..66abe01 100644 --- a/tests/peer-topology.test.js +++ b/tests/peer-topology.test.js @@ -5,7 +5,7 @@ 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, @@ -13,7 +13,7 @@ test("root sessions keep user and root agent as peers", () => { }) 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() @@ -23,7 +23,7 @@ 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", @@ -31,7 +31,7 @@ test("classic peer model keeps delegated sessions on the Claude-style user and a }) 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() diff --git a/tests/user-peer-id.test.js b/tests/user-peer-id.test.js new file mode 100644 index 0000000..f9cbffc --- /dev/null +++ b/tests/user-peer-id.test.js @@ -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") +})