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
2 changes: 2 additions & 0 deletions .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions bridges/kimaki/plugins/kimaki-session-attribution.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<Plugin>> & {
"shell.env": (
input: { cwd: string; sessionID?: string; callID?: string },
output: { env: Record<string, string> },
) => Promise<void>;
};

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<SessionAwareHooks> => {
const cache = new Map<string, Promise<string | null>>();

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<string | null> {
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;
2 changes: 1 addition & 1 deletion bridges/kimaki/post-upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/repair-opencode-json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions runtimes/opencode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)\","
Expand Down
12 changes: 12 additions & 0 deletions scripts/kimaki-managed-plugin-rig.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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: [],
}
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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`,
Expand All @@ -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 })
}
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tests/kimaki-managed-plugin-rig.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
75 changes: 75 additions & 0 deletions tests/kimaki-session-attribution.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions tests/opencode-local-plugin-path.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions tests/post-upgrade-restore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"
2 changes: 2 additions & 0 deletions tests/repair-opencode-json.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand All @@ -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')}")
Expand Down
Loading