From b84a431d06f94b7b28802b63dd902314b34f6cf5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 7 Aug 2026 12:12:36 -0400 Subject: [PATCH] fix: bridge Kimaki session attribution Install a temporary OpenCode shell.env adapter that resolves each Kimaki session to its Discord thread until native attribution ships. AI assistance: OpenAI gpt-5.6-sol via OpenCode implemented and verified the adapter, lifecycle wiring, and regression coverage. Chris Huber reviewed and remains responsible for the change. --- .github/workflows/shell.yml | 2 + .../plugins/kimaki-session-attribution.ts | 90 +++++++++++++++++++ bridges/kimaki/post-upgrade.sh | 2 +- lib/repair-opencode-json.py | 3 +- runtimes/opencode.sh | 2 + scripts/kimaki-managed-plugin-rig.mjs | 12 +++ tests/kimaki-managed-plugin-rig.sh | 1 + tests/kimaki-session-attribution.mjs | 75 ++++++++++++++++ tests/opencode-local-plugin-path.sh | 2 + tests/post-upgrade-restore.sh | 12 ++- tests/repair-opencode-json.sh | 2 + 11 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 bridges/kimaki/plugins/kimaki-session-attribution.ts create mode 100644 tests/kimaki-session-attribution.mjs diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 0b9df4d..b49c0e7 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -56,6 +56,8 @@ jobs: - uses: oven-sh/setup-bun@v2 - name: Run tests/dm-agent-sync.mjs run: bun tests/dm-agent-sync.mjs + - name: Run tests/kimaki-session-attribution.mjs + run: bun tests/kimaki-session-attribution.mjs cli-transport: name: CLI dispatch transport runtime diff --git a/bridges/kimaki/plugins/kimaki-session-attribution.ts b/bridges/kimaki/plugins/kimaki-session-attribution.ts new file mode 100644 index 0000000..a91f859 --- /dev/null +++ b/bridges/kimaki/plugins/kimaki-session-attribution.ts @@ -0,0 +1,90 @@ +// Temporary compatibility bridge for remorses/kimaki#137. +// Remove after the native KIMAKI_THREAD_ID contract ships and is installed. + +import { spawn } from "node:child_process"; +import type { Plugin, PluginInput } from "@opencode-ai/plugin"; + +type SessionAwareHooks = Awaited> & { + "shell.env": ( + input: { cwd: string; sessionID?: string; callID?: string }, + output: { env: Record }, + ) => Promise; +}; + +const DISCORD_THREAD_URL = /^https:\/\/discord\.com\/channels\/\d{17,20}\/(\d{17,20})\/?$/; +const LOOKUP_TIMEOUT_MS = 2_000; +const OUTPUT_LIMIT = 1_024; + +const sessionAttribution = (async (_input: PluginInput): Promise => { + const cache = new Map>(); + + return { + "shell.env": async ({ sessionID }, output) => { + if (!sessionID || output.env.KIMAKI_THREAD_ID) { + return; + } + + let lookup = cache.get(sessionID); + if (!lookup) { + lookup = resolveThreadId(sessionID); + cache.set(sessionID, lookup); + } + + const threadId = await lookup; + if (!threadId) { + if (cache.get(sessionID) === lookup) { + cache.delete(sessionID); + } + return; + } + + if (!output.env.KIMAKI_THREAD_ID) { + output.env.KIMAKI_THREAD_ID = threadId; + } + }, + event: async ({ event }) => { + if (event.type === "session.deleted") { + cache.delete(event.properties.info.id); + } + }, + }; +}) satisfies Plugin; + +function resolveThreadId(sessionID: string): Promise { + return new Promise((resolve) => { + const child = spawn(process.env.KIMAKI_BIN || "kimaki", ["session", "discord-url", sessionID], { + shell: false, + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + let settled = false; + const finish = (threadId: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(threadId); + }; + const timeout = setTimeout(() => { + child.kill(); + finish(null); + }, LOOKUP_TIMEOUT_MS); + + child.stdout.on("data", (chunk) => { + stdout += chunk; + if (stdout.length > OUTPUT_LIMIT) { + child.kill(); + finish(null); + } + }); + child.on("error", () => finish(null)); + child.on("close", (code) => { + if (code !== 0 || stdout.length > OUTPUT_LIMIT) { + finish(null); + return; + } + finish(stdout.trim().match(DISCORD_THREAD_URL)?.[1] ?? null); + }); + }); +} + +export default sessionAttribution; diff --git a/bridges/kimaki/post-upgrade.sh b/bridges/kimaki/post-upgrade.sh index 3472bfc..b31dd36 100755 --- a/bridges/kimaki/post-upgrade.sh +++ b/bridges/kimaki/post-upgrade.sh @@ -67,7 +67,7 @@ else SKILLS_DIR="/usr/lib/node_modules/kimaki/skills" fi -REQUIRED_PLUGINS=(dm-context-filter.ts dm-agent-sync.ts) +REQUIRED_PLUGINS=(dm-context-filter.ts dm-agent-sync.ts kimaki-session-attribution.ts) WP_CODING_AGENTS_SKILLS=(upgrade-wp-coding-agents) if [[ -n "${KIMAKI_DIST_DIR:-}" ]]; then diff --git a/lib/repair-opencode-json.py b/lib/repair-opencode-json.py index 97f4ab5..acb7f17 100755 --- a/lib/repair-opencode-json.py +++ b/lib/repair-opencode-json.py @@ -73,7 +73,7 @@ from typing import List, Tuple -MANAGED_KIMAKI_PLUGIN_NAMES = {"dm-context-filter.ts", "dm-agent-sync.ts"} +MANAGED_KIMAKI_PLUGIN_NAMES = {"dm-context-filter.ts", "dm-agent-sync.ts", "kimaki-session-attribution.ts"} OBSOLETE_KIMAKI_PLUGIN_NAMES = {"homeboy-notification-context.ts"} DM_MEMORY_MARKER = "/datamachine-files/" # Every installed path wp-coding-agents manages, as ready-made edit patterns in @@ -138,6 +138,7 @@ def expected_plugins( if chat_bridge == "kimaki": plugins.append(f"{kimaki_plugins_dir}/dm-context-filter.ts") plugins.append(f"{kimaki_plugins_dir}/dm-agent-sync.ts") + plugins.append(f"{kimaki_plugins_dir}/kimaki-session-attribution.ts") if claude_code_auth_plugin: plugins.append(claude_code_auth_plugin) diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index e01928e..499c471 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -201,6 +201,7 @@ runtime_generate_config() { mkdir -p "$KIMAKI_PLUGINS_DIR" cp "$SCRIPT_DIR/bridges/kimaki/plugins/dm-context-filter.ts" "$KIMAKI_PLUGINS_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/bridges/kimaki/plugins/dm-agent-sync.ts" "$KIMAKI_PLUGINS_DIR/" 2>/dev/null || true + cp "$SCRIPT_DIR/bridges/kimaki/plugins/kimaki-session-attribution.ts" "$KIMAKI_PLUGINS_DIR/" 2>/dev/null || true fi else KIMAKI_PLUGINS_DIR="/opt/kimaki-config/plugins" @@ -238,6 +239,7 @@ runtime_generate_config() { if [ "$CHAT_BRIDGE" = "kimaki" ]; then OPENCODE_PLUGINS="${OPENCODE_PLUGINS}\n \"${KIMAKI_PLUGINS_DIR}/dm-context-filter.ts\"," OPENCODE_PLUGINS="${OPENCODE_PLUGINS}\n \"${KIMAKI_PLUGINS_DIR}/dm-agent-sync.ts\"," + OPENCODE_PLUGINS="${OPENCODE_PLUGINS}\n \"${KIMAKI_PLUGINS_DIR}/kimaki-session-attribution.ts\"," fi if opencode_claude_code_auth_enabled; then OPENCODE_PLUGINS="${OPENCODE_PLUGINS}\n \"$(opencode_claude_code_auth_plugin_path)\"," diff --git a/scripts/kimaki-managed-plugin-rig.mjs b/scripts/kimaki-managed-plugin-rig.mjs index 67725ca..dc69683 100755 --- a/scripts/kimaki-managed-plugin-rig.mjs +++ b/scripts/kimaki-managed-plugin-rig.mjs @@ -87,6 +87,7 @@ function stageManagedConfig() { copyFile(path.join(repoPluginsDir, 'dm-context-filter.ts'), path.join(stagedPluginsDir, 'dm-context-filter.ts')) copyFile(path.join(repoPluginsDir, 'dm-agent-sync.ts'), path.join(stagedPluginsDir, 'dm-agent-sync.ts')) + copyFile(path.join(repoPluginsDir, 'kimaki-session-attribution.ts'), path.join(stagedPluginsDir, 'kimaki-session-attribution.ts')) copyFile(path.join(repoKimakiDir, 'post-upgrade.sh'), postUpgradePath) fs.chmodSync(postUpgradePath, 0o755) @@ -110,6 +111,7 @@ function writeOpencodeConfig() { plugin: [ path.join(stagedPluginsDir, 'dm-context-filter.ts'), path.join(stagedPluginsDir, 'dm-agent-sync.ts'), + path.join(stagedPluginsDir, 'kimaki-session-attribution.ts'), ], instructions: [], } @@ -121,6 +123,7 @@ function recordStaticEvidence() { artifacts.files['site/opencode.json'] = fileRecord(path.join(siteDir, 'opencode.json')) artifacts.files['kimaki-config/plugins/dm-context-filter.ts'] = fileRecord(path.join(stagedPluginsDir, 'dm-context-filter.ts')) artifacts.files['kimaki-config/plugins/dm-agent-sync.ts'] = fileRecord(path.join(stagedPluginsDir, 'dm-agent-sync.ts')) + artifacts.files['kimaki-config/plugins/kimaki-session-attribution.ts'] = fileRecord(path.join(stagedPluginsDir, 'kimaki-session-attribution.ts')) artifacts.files['kimaki-config/post-upgrade.sh'] = fileRecord(postUpgradePath) for (const candidate of ['skills-enable-list.txt', 'skills-disable-list.txt']) { const file = path.join(kimakiConfigDir, candidate) @@ -165,6 +168,7 @@ async function recordLiveDriftEvidence() { const liveFreshnessFiles = [ path.join(livePluginsDir, 'dm-context-filter.ts'), path.join(livePluginsDir, 'dm-agent-sync.ts'), + path.join(livePluginsDir, 'kimaki-session-attribution.ts'), path.join(liveConfigDir, 'post-upgrade.sh'), path.join(liveConfigDir, skillListName), liveLaunchdPlist, @@ -185,6 +189,12 @@ async function recordLiveDriftEvidence() { liveFile: path.join(livePluginsDir, 'dm-agent-sync.ts'), live, }) + compareFile({ + label: 'kimaki-session-attribution installed copy matches repo', + repoFile: path.join(repoPluginsDir, 'kimaki-session-attribution.ts'), + liveFile: path.join(livePluginsDir, 'kimaki-session-attribution.ts'), + live, + }) compareFile({ label: `${skillListName} installed copy matches repo`, @@ -207,6 +217,7 @@ async function recordLiveDriftEvidence() { live.opencode_plugins = pluginList liveCheck(pluginList.includes(path.join(livePluginsDir, 'dm-context-filter.ts')), 'opencode.json references live dm-context-filter path', live) liveCheck(pluginList.includes(path.join(livePluginsDir, 'dm-agent-sync.ts')), 'opencode.json references live dm-agent-sync path', live) + liveCheck(pluginList.includes(path.join(livePluginsDir, 'kimaki-session-attribution.ts')), 'opencode.json references live kimaki-session-attribution path', live) } else { liveCheck(false, 'live opencode.json exists', live, { file: opencodeJson }) } @@ -405,6 +416,7 @@ async function runCycle({ name, simulatePackageWipe }) { assert(fs.existsSync(path.join(stagedSkillsDir, 'upgrade-wp-coding-agents', 'SKILL.md')), `${name}: persistent upgrade skill source remains present`, cycle) assert(fs.existsSync(path.join(stagedPluginsDir, 'dm-context-filter.ts')), `${name}: context filter present after restart`, cycle) assert(fs.existsSync(path.join(stagedPluginsDir, 'dm-agent-sync.ts')), `${name}: agent sync present after restart`, cycle) + assert(fs.existsSync(path.join(stagedPluginsDir, 'kimaki-session-attribution.ts')), `${name}: session attribution bridge present after restart`, cycle) const permission = expectedSkillPermission() assert(permission?.['*'] === 'deny', `${name}: generated skill permission denies unlisted skills`, cycle) diff --git a/tests/kimaki-managed-plugin-rig.sh b/tests/kimaki-managed-plugin-rig.sh index b4e8bf9..8a9e6a4 100755 --- a/tests/kimaki-managed-plugin-rig.sh +++ b/tests/kimaki-managed-plugin-rig.sh @@ -134,6 +134,7 @@ for (const cycle of manifest.cycles) { `${cycle.name}: bundled critique skill removed from npm skills dir`, `${cycle.name}: package-local upgrade skill duplicate removed`, `${cycle.name}: persistent upgrade skill source remains present`, + `${cycle.name}: session attribution bridge present after restart`, `${cycle.name}: generated skill permission denies unlisted skills`, `${cycle.name}: generated skill permission allows upgrade skill`, `${cycle.name}: dm-context-filter hook executed`, diff --git a/tests/kimaki-session-attribution.mjs b/tests/kimaki-session-attribution.mjs new file mode 100644 index 0000000..cb41c8f --- /dev/null +++ b/tests/kimaki-session-attribution.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(import.meta.dirname, ".."); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "kimaki-session-attribution-")); +const calls = path.join(temp, "calls.jsonl"); +const kimaki = path.join(temp, "kimaki-fixture.mjs"); +fs.writeFileSync( + kimaki, + `#!/usr/bin/env node +import fs from "node:fs"; +fs.appendFileSync(process.env.KIMAKI_CALL_LOG, JSON.stringify(process.argv.slice(2)) + "\\n"); +const session = process.argv.at(-1); +if (session === "missing") process.exit(1); +if (session === "invalid") process.stdout.write("https://example.com/token=secret\\n"); +else process.stdout.write("https://discord.com/channels/123456789012345678/" + (session === "two" ? "423456789012345678" : "323456789012345678") + "\\n"); +`, + { mode: 0o755 }, +); + +process.env.KIMAKI_BIN = kimaki; +process.env.KIMAKI_CALL_LOG = calls; + +try { + const pluginPath = path.join(root, "bridges", "kimaki", "plugins", "kimaki-session-attribution.ts"); + const plugin = (await import(pathToFileURL(pluginPath).href)).default; + const hooks = await plugin({}); + + const first = { env: {} }; + const duplicate = { env: {} }; + await Promise.all([ + hooks["shell.env"]({ cwd: root, sessionID: "one" }, first), + hooks["shell.env"]({ cwd: root, sessionID: "one" }, duplicate), + ]); + assert.equal(first.env.KIMAKI_THREAD_ID, "323456789012345678"); + assert.equal(duplicate.env.KIMAKI_THREAD_ID, "323456789012345678"); + assert.equal(readCalls().length, 1, "concurrent lookups should share one process"); + + const second = { env: {} }; + await hooks["shell.env"]({ cwd: root, sessionID: "two" }, second); + assert.equal(second.env.KIMAKI_THREAD_ID, "423456789012345678"); + + const native = { env: { KIMAKI_THREAD_ID: "523456789012345678" } }; + await hooks["shell.env"]({ cwd: root, sessionID: "native" }, native); + assert.equal(native.env.KIMAKI_THREAD_ID, "523456789012345678"); + assert.equal(readCalls().length, 2, "native attribution should skip the bridge lookup"); + + for (const sessionID of [undefined, "missing", "invalid"]) { + const output = { env: {} }; + await hooks["shell.env"]({ cwd: root, sessionID }, output); + assert.equal(output.env.KIMAKI_THREAD_ID, undefined); + } + + await hooks.event({ event: { type: "session.deleted", properties: { info: { id: "one" } } } }); + await hooks["shell.env"]({ cwd: root, sessionID: "one" }, { env: {} }); + assert.equal(readCalls().filter((args) => args.at(-1) === "one").length, 2, "deletion should evict the cache"); + + for (const args of readCalls()) { + assert.deepEqual(args.slice(0, 2), ["session", "discord-url"]); + assert.equal(args.length, 3, "session ID must be passed as one positional argv value"); + } + console.log("PASS: tests/kimaki-session-attribution.mjs"); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} + +function readCalls() { + if (!fs.existsSync(calls)) return []; + return fs.readFileSync(calls, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); +} diff --git a/tests/opencode-local-plugin-path.sh b/tests/opencode-local-plugin-path.sh index c0dda0c..6d60783 100755 --- a/tests/opencode-local-plugin-path.sh +++ b/tests/opencode-local-plugin-path.sh @@ -48,6 +48,7 @@ with open(opencode_json, encoding="utf-8") as handle: expected = [ f"{kimaki_data_dir}/kimaki-config/plugins/dm-context-filter.ts", f"{kimaki_data_dir}/kimaki-config/plugins/dm-agent-sync.ts", + f"{kimaki_data_dir}/kimaki-config/plugins/kimaki-session-attribution.ts", f"{opencode_json.rsplit('/', 1)[0]}/.opencode/plugins/claude-code-auth.ts", ] actual = data.get("plugin") @@ -90,6 +91,7 @@ with open(opencode_json, encoding="utf-8") as handle: expected = [ f"{kimaki_data_dir}/kimaki-config/plugins/dm-context-filter.ts", f"{kimaki_data_dir}/kimaki-config/plugins/dm-agent-sync.ts", + f"{kimaki_data_dir}/kimaki-config/plugins/kimaki-session-attribution.ts", ] actual = data.get("plugin") if actual != expected: diff --git a/tests/post-upgrade-restore.sh b/tests/post-upgrade-restore.sh index 215b9be..a3ac42e 100755 --- a/tests/post-upgrade-restore.sh +++ b/tests/post-upgrade-restore.sh @@ -89,6 +89,10 @@ cat > "$SRC_PLUGINS/dm-agent-sync.ts" <<'EOF' // dm-agent-sync.ts export default async () => ({}) EOF +cat > "$SRC_PLUGINS/kimaki-session-attribution.ts" <<'EOF' +// kimaki-session-attribution.ts +export default async () => ({}) +EOF echo "obsolete" > "$SRC_PLUGINS/homeboy-notification-context.ts" TEST_SCRIPT_DIR="$TMP/kimaki-config-dir" @@ -178,9 +182,11 @@ KIMAKI_PLUGIN_SOURCE_DIR="$SRC_PLUGINS" \ assert_present "$LIVE_PLUGINS/dm-context-filter.ts" assert_present "$LIVE_PLUGINS/dm-agent-sync.ts" +assert_present "$LIVE_PLUGINS/kimaki-session-attribution.ts" assert_missing "$LIVE_PLUGINS/homeboy-notification-context.ts" assert_log_contains_file "$TMP/run-override.log" "restored plugin dm-context-filter.ts" assert_log_contains_file "$TMP/run-override.log" "restored plugin dm-agent-sync.ts" +assert_log_contains_file "$TMP/run-override.log" "restored plugin kimaki-session-attribution.ts" # Idempotency: second run with the same state should restore zero plugins. KIMAKI_SKILLS_DIR="$LIVE_SKILLS" \ @@ -216,8 +222,8 @@ if [[ ! -f "$LIVE_PLUGINS/dm-context-filter.ts" ]]; then cat "$TMP/run3.log" exit 1 fi -if ! grep -q "2 plugins restored" "$TMP/run3.log"; then - echo "FAIL: rehydration run should report 2 plugins restored" +if ! grep -q "3 plugins restored" "$TMP/run3.log"; then + echo "FAIL: rehydration run should report 3 plugins restored" cat "$TMP/run3.log" exit 1 fi @@ -288,6 +294,6 @@ KIMAKI_PLUGIN_SOURCE_DIR="$MISSING_SRC" \ assert_log_contains_file "$TMP/missing.log" "WARNING: persistent plugin source dir not found at $MISSING_SRC; managed OpenCode plugins cannot be loaded" assert_log_contains_file "$TMP/missing.log" "WARNING: plugins dir not found at $MISSING_LIVE_PLUGINS; opencode.json plugin paths will be skipped by OpenCode" -assert_log_contains_file "$TMP/missing.log" "2 required plugins missing" +assert_log_contains_file "$TMP/missing.log" "3 required plugins missing" echo "PASS: tests/post-upgrade-restore.sh ($(grep -c '' "$TMP/run1.log" || true) lines run1, $(grep -c '' "$TMP/run3.log" || true) lines run3)" diff --git a/tests/repair-opencode-json.sh b/tests/repair-opencode-json.sh index 63241c3..33a3182 100755 --- a/tests/repair-opencode-json.sh +++ b/tests/repair-opencode-json.sh @@ -113,6 +113,7 @@ cat > "$TMP/local-plugin-path.json" <<'JSON' "plugin": [ "/Users/example/.nvm/versions/node/v24/lib/node_modules/kimaki/plugins/dm-context-filter.ts", "/Users/example/.nvm/versions/node/v24/lib/node_modules/kimaki/plugins/dm-agent-sync.ts", + "/Users/example/.nvm/versions/node/v24/lib/node_modules/kimaki/plugins/kimaki-session-attribution.ts", "/Users/example/.nvm/versions/node/v24/lib/node_modules/kimaki/plugins/homeboy-notification-context.ts" ] } @@ -135,6 +136,7 @@ with open(sys.argv[1], encoding="utf-8") as handle: expected = [ "/Users/example/.kimaki/kimaki-config/plugins/dm-context-filter.ts", "/Users/example/.kimaki/kimaki-config/plugins/dm-agent-sync.ts", + "/Users/example/.kimaki/kimaki-config/plugins/kimaki-session-attribution.ts", ] if data.get("plugin") != expected: raise SystemExit(f"unexpected plugin paths: {data.get('plugin')}")