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: 1 addition & 1 deletion src/agent-runtime/omp-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function CanvasTTYLifecycle(api) {
}
}

for (const event of ["session_start", "session_switch", "session_branch"]) {
for (const event of ["session_start", "session_switch", "session_branch", "session_fork"]) {
api.on(event, (_event, ctx) => report(ctx, "idle", event));
}
api.on("agent_start", (_event, ctx) => report(ctx, "working", "UserPromptSubmit"));
Expand Down
3 changes: 2 additions & 1 deletion src/agent-runtime/opencode-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ async function lifecycleEvent(event, decisions) {
// A call a plugin allowed is answered for every session of this OpenCode (subagents included).
if (event.type === "permission.asked" && await decisions.permissionAsked(properties)) return;

if (event.type === "session.created") {
// A resumed root session emits updates, not a second creation event.
if (event.type === "session.created" || (!rootSessionId && event.type === "session.updated")) {
const session = info ?? properties;
if (session.parentID || session.parentId) return;
rootSessionId = stringField(session.id, sessionId);
Expand Down
6 changes: 5 additions & 1 deletion src/agent-runtime/runtime-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ const MINIMAX_SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;

export function normalizeThreadId(provider, value) {
if (typeof value !== "string") return undefined;
if (["codex", "claude", "grok", "qwen", "kimi", "pi", "omp", "cursor"].includes(provider)) {
if (provider === "kimi") {
const uuid = value.startsWith("session_") ? value.slice(8) : value.startsWith("ses_") ? value.slice(4) : value;
return CANONICAL_UUID_RE.test(uuid) ? value.toLowerCase() : undefined;
}
if (["codex", "claude", "grok", "qwen", "pi", "omp", "cursor"].includes(provider)) {
return CANONICAL_UUID_RE.test(value) ? value.toLowerCase() : undefined;
}
if (provider === "opencode") return OPENCODE_SESSION_RE.test(value) ? value : undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/main/services/AgentChatHistoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class AgentChatHistoryService {
opencode: opencodeHistory(paths.opencode),
claude: jsonlHistory("claude", paths.claude),
qwen: jsonlHistory("qwen", paths.qwen),
kimi: kimiHistory(paths.kimi),
kimi: kimiHistory(paths.kimi, paths.kimiCode),
omp: jsonlHistory("omp", paths.omp),
pi: jsonlHistory("pi", paths.pi),
cursor: cursorHistory(paths.cursor),
Expand Down
1 change: 1 addition & 0 deletions src/main/services/agent-history/historyPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function resolveAgentHistoryPaths(options: HistoryPathOptions = {}) {
claude: path.join(env("CLAUDE_CONFIG_DIR") || path.join(home, ".claude"), "projects"),
qwen: [path.join(qwen, "projects"), path.join(qwen, "tmp")],
kimi: env("KIMI_SHARE_DIR") || path.join(home, ".kimi"),
kimiCode: env("KIMI_CODE_HOME") || path.join(home, ".kimi-code"),
omp: env("PI_CODING_AGENT_SESSION_DIR") || path.join(ompData, "sessions"),
pi: env("PI_CODING_AGENT_SESSION_DIR") ? expand(env("PI_CODING_AGENT_SESSION_DIR")!)
: path.join(expand(env("PI_CODING_AGENT_DIR") || path.join(home, ".pi", "agent")), "sessions"),
Expand Down
66 changes: 63 additions & 3 deletions src/main/services/agent-history/kimiHistory.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,71 @@
import { createHash } from "node:crypto";
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path";
import { basename, dirname, join } from "node:path";
import { normalizeThreadId } from "../../../agent-runtime/runtime-protocol.mjs";
import type { AgentChatHistoryItem } from "../../../shared/contracts.ts";
import { historyItem, historyPromptTitle, missing, readJson, readJsonMetadata, record, type HistoryAdapter } from "./historyFiles.ts";
import { historyFiles, historyItem, historyPromptTitle, missing, readJson, readJsonLines, readJsonMetadata, record, type HistoryAdapter } from "./historyFiles.ts";

export function kimiHistory(home: string): HistoryAdapter {
export function kimiHistory(home: string, codeHome?: string): HistoryAdapter {
const legacy = legacyKimiHistory(home);
return { async read(signal) {
if (!codeHome) return legacy.read(signal);
let modern: Awaited<ReturnType<HistoryAdapter["read"]>> | undefined;
let modernError: unknown;
try { modern = await kimiCodeHistory(codeHome).read(signal); }
catch (error) { if (!missing(error)) throw error; modernError = error; }
let old: Awaited<ReturnType<HistoryAdapter["read"]>> | undefined;
let legacyError: unknown;
try { old = await legacy.read(signal); }
catch (error) { if (!missing(error)) throw error; legacyError = error; }
if (!modern && !old) throw legacyError ?? modernError;
const modernItems = modern?.items ?? [];
const migratedLegacyIds = new Set(modernItems.map(legacyIdForModern).filter((id): id is string => Boolean(id)));
return {
items: [...modernItems, ...(old?.items ?? []).filter(item => !migratedLegacyIds.has(item.id))],
skipped: (modern?.skipped ?? 0) + (old?.skipped ?? 0)
};
} };
}

function legacyIdForModern(item: AgentChatHistoryItem): string | undefined {
const native = item.id.startsWith("session_") ? item.id.slice(8) : item.id.startsWith("ses_") ? item.id.slice(4) : "";
return normalizeThreadId("kimi", native);
}

function kimiCodeHistory(home: string): HistoryAdapter {
return { async read(signal) {
const items: AgentChatHistoryItem[] = [];
let skipped = 0;
for await (const path of historyFiles(join(home, "sessions"), name => name === "state.json", signal)) {
const id = basename(dirname(path));
if ((!id.startsWith("session_") && !id.startsWith("ses_")) || !normalizeThreadId("kimi", id)) continue;
try {
const state = record(await readJson(path));
if (state.id !== id || state.version !== 2) { skipped += 1; continue; }
if (record(state.custom).child_session_kind === "child") continue;
let title = historyPromptTitle(state.title) || historyPromptTitle(state.lastPrompt);
if (!title) {
try {
await readJsonLines(join(dirname(path), "agents", "main", "wire.jsonl"), value => {
const row = record(value);
if (row.type === "turn.prompt" && row.agentId === "main" && record(row.origin).kind === "user") {
title = historyPromptTitle(row.input) || title;
}
}, signal, line => /"type"\s*:\s*"turn\.prompt"/.test(line));
} catch (error) { if (!missing(error)) throw error; }
}
const item = historyItem("kimi", {
id, cwd: state.cwd, title,
lastActivityAt: state.updatedAt ?? state.createdAt
});
if (item) items.push(item); else skipped += 1;
} catch { signal.throwIfAborted(); skipped += 1; }
}
return { items, skipped };
} };
}

function legacyKimiHistory(home: string): HistoryAdapter {
return { async read(signal) {
signal.throwIfAborted();
const metadata = record(await readJson(join(home, "kimi.json")));
Expand Down
8 changes: 4 additions & 4 deletions src/main/services/agent-runtime/ProviderRuntimeLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export interface PreparedProviderRuntimeLaunch {
releaseConfiguration(): void;
}

const HOOK_PROVIDERS: ReadonlySet<string> = new Set(["claude", "codex", "qwen", "opencode", "kimi", "hermes", "grok", "omp"]);
const HOOK_PROVIDERS: ReadonlySet<string> = new Set(["claude", "codex", "qwen", "opencode", "kimi", "hermes", "grok", "omp", "pi"]);

export class ProviderRuntimeLaunchAdapters {
private readonly options: ProviderRuntimeLaunchOptions;
Expand Down Expand Up @@ -187,8 +187,8 @@ export class ProviderRuntimeLaunchAdapters {
...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs) : [])
];
const openCodeDecisions = gate && provider === "opencode";
// Only providers with a hook adapter get lifecycle configuration. Anything else (pi,
// cursor, minimax, devin, antigravity) must never reach Grok's shared hook overlay.
// Only providers with a hook adapter get lifecycle configuration. Cursor,
// MiniMax, Devin and Antigravity must never reach Grok's shared hook overlay.
const hasHooks = HOOK_PROVIDERS.has(provider)
&& (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions)));
const environment = hasHooks
Expand Down Expand Up @@ -235,7 +235,7 @@ export class ProviderRuntimeLaunchAdapters {
)
});
}
if (provider === "omp") {
if (provider === "omp" || provider === "pi") {
return prepared(coreHooksEnabled
? ["--extension", join(dirname(this.options.openCodePluginPath), "omp-extension.mjs")]
: [], environment);
Expand Down
111 changes: 111 additions & 0 deletions tests/agent-chat-history.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,67 @@ async function jsonl(path, rows) {
await writeFile(path, rows.map(row => JSON.stringify(row)).join("\n") + "\n");
await utimes(path, at / 1000, at / 1000);
}
async function legacyConversation(home, cwd, sessionId, title = "") {
await mkdir(home, { recursive: true });
await writeFile(join(home, "kimi.json"), JSON.stringify({ work_dirs: [{ path: cwd }] }));
const dir = join(home, "sessions", createHash("md5").update(cwd).digest("hex"), sessionId);
await mkdir(dir, { recursive: true });
await jsonl(join(dir, "context.jsonl"), [{ role: "user", content: title || "Legacy prompt" }]);
if (title) await writeFile(join(dir, "state.json"), JSON.stringify({ custom_title: title }));
}
async function modernState(home, sessionId, fields = {}) {
const dir = join(home, "sessions", "wd_project", sessionId);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "state.json"), JSON.stringify({
id: sessionId, version: 2, cwd: fields.cwd, title: fields.title || "Modern title",
createdAt: at - 1000, updatedAt: at, custom: {}, ...fields
}));
}

test("Kimi retains legacy conversations when the modern sessions store is empty", async t => {
const root = await directory(t);
const legacy = join(root, "legacy");
const modern = join(root, "modern");
await legacyConversation(legacy, root, id, "Legacy title");
await mkdir(join(modern, "sessions"), { recursive: true });
const result = await kimiHistory(legacy, modern).read(signal());
assert.deepEqual(result.items.map(item => item.id), [id]);
});

test("Kimi retains legacy conversations when modern records are excluded or invalid", async t => {
const root = await directory(t);
const legacy = join(root, "legacy");
const modern = join(root, "modern");
await legacyConversation(legacy, root, id, "Legacy title");
await modernState(modern, `session_${id}`, { custom: { child_session_kind: "child" } });
await modernState(modern, "session_22222222-2222-4222-8222-222222222222", { version: 1 });
const result = await kimiHistory(legacy, modern).read(signal());
assert.deepEqual(result.items.map(item => item.id), [id]);
});

test("Kimi prefers the native ID for migrated history and keeps distinct legacy chats with equal titles", async t => {
const root = await directory(t);
const legacy = join(root, "legacy");
const modern = join(root, "modern");
const otherId = "22222222-2222-4222-8222-222222222222";
await legacyConversation(legacy, root, id, "Same title");
await legacyConversation(legacy, root, otherId, "Same title");
await modernState(modern, `session_${id}`, {
title: "Migrated renamed title",
custom: { imported_from_kimi_cli: true, kimi_cli_session_id: id }
});
const result = await kimiHistory(legacy, modern).read(signal());
assert.deepEqual(result.items.map(item => item.id), [`session_${id}`, otherId]);
assert.equal(result.items[0].title, "Migrated renamed title");
});

test("Kimi accepts the native ses ID emitted by the legacy migrator", async t => {
const root = await directory(t);
const modern = join(root, "modern");
await modernState(modern, `ses_${id}`, { cwd: root, title: "Imported" });
const result = await kimiHistory(join(root, "missing-legacy"), modern).read(signal());
assert.deepEqual(result.items.map(item => item.id), [`ses_${id}`]);
});

test("OpenCode activity stays in milliseconds", async t => {
const root = await directory(t);
Expand All @@ -35,6 +96,55 @@ test("OpenCode activity stays in milliseconds", async t => {
assert.deepEqual(result.items, [{ provider: "opencode", id: "ses_abc123", title: "Project chat", cwd: root, lastActivityAt: at }]);
});

test("Kimi Code reads current session metadata, omits child sessions and prefers it over migrated legacy copies", async t => {
const root = await directory(t);
const legacy = join(root, "legacy");
const current = join(root, "current");
const currentId = `session_${id}`;
const session = join(current, "sessions", "wd_project", currentId);
await mkdir(session, { recursive: true });
await mkdir(legacy);
await writeFile(join(legacy, "kimi.json"), JSON.stringify({ work_dirs: [{ path: root }] }));
const migrated = join(legacy, "sessions", createHash("md5").update(root).digest("hex"), id);
await mkdir(migrated, { recursive: true });
await jsonl(join(migrated, "context.jsonl"), [{ role: "user", content: "Outdated legacy title" }]);
await writeFile(join(session, "state.json"), JSON.stringify({
id: currentId, version: 2, cwd: root, title: "Renamed current chat",
createdAt: at - 1000, updatedAt: at, custom: {}, agents: { main: { type: "main" } }
}));
const childId = "session_22222222-2222-4222-8222-222222222222";
const child = join(current, "sessions", "wd_project", childId);
await mkdir(child, { recursive: true });
await writeFile(join(child, "state.json"), JSON.stringify({
id: childId, version: 2, cwd: root, updatedAt: at, custom: { child_session_kind: "child", parent_session_id: currentId }
}));
const result = await kimiHistory(legacy, current).read(signal());
assert.deepEqual(result.items, [{ provider: "kimi", id: currentId, cwd: root, title: "Renamed current chat", lastActivityAt: at }]);
const launch = resolveTerminalLaunch("kimi", "normal", [], {
providerCli: { provider: "kimi", state: "available", executable: "/resolved/kimi", launcher: "native", environment: {} },
resumePrevious: true, resumeThreadId: currentId
});
assert.deepEqual(launch.args, ["--session", currentId]);
});

test("Kimi Code derives an unnamed chat's title from its last user prompt in the main agent wire log", async t => {
const root = await directory(t);
const currentId = `session_${id}`;
const session = join(root, "sessions", "wd_project", currentId);
await mkdir(join(session, "agents", "main"), { recursive: true });
await writeFile(join(session, "state.json"), JSON.stringify({
id: currentId, version: 2, cwd: root, updatedAt: at, custom: {}
}));
await jsonl(join(session, "agents", "main", "wire.jsonl"), [
{ type: "profile.bind", systemPrompt: "x".repeat(80 * 1024) },
{ type: "turn.prompt", agentId: "main", origin: { kind: "user" }, input: [{ type: "text", text: "First question" }] },
{ type: "turn.prompt", agentId: "main", origin: { kind: "user" }, input: [{ type: "text", text: "Latest question" }] },
{ type: "turn.prompt", agentId: "main", origin: { kind: "system" }, input: [{ type: "text", text: "Internal reminder" }] }
]);
const result = await kimiHistory(join(root, "legacy"), root).read(signal());
assert.equal(result.items[0]?.title, "Latest question");
});

test("a complete metadata record exactly at the tail boundary is retained", async t => {
const root = await directory(t);
const path = join(root, "boundary.jsonl");
Expand Down Expand Up @@ -89,6 +199,7 @@ test("Kimi lists local workspaces with custom titles and excludes remote workspa
await writeFile(join(dir, "state.json"), JSON.stringify({ custom_title: "Renamed" }));
const result = await kimiHistory(root).read(signal());
assert.deepEqual(result.items, [{ provider: "kimi", id, cwd: root, title: "Renamed", lastActivityAt: at }]);
assert.deepEqual(await kimiHistory(root, join(root, "absent-current-store")).read(signal()), result);
});

test("MiniMax extracts only metadata from the confirmed runtime store", async t => {
Expand Down
18 changes: 14 additions & 4 deletions tests/agent-history-paths.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { outputFiles } = await build({
const { AgentChatHistoryService } = await import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}`);

const id = "11111111-1111-4111-8111-111111111111";
const pathVariables = ["OPENCODE_HOME", "XDG_DATA_HOME", "PI_CONFIG_DIR", "PI_CODING_AGENT_DIR",
const pathVariables = ["OPENCODE_HOME", "XDG_DATA_HOME", "PI_CONFIG_DIR", "PI_CODING_AGENT_DIR", "KIMI_CODE_HOME", "KIMI_SHARE_DIR",
"PI_CODING_AGENT_SESSION_DIR", "OMP_PROFILE", "PI_PROFILE", "QWEN_CODE_HOME", "QWEN_HOME", "QWEN_RUNTIME_DIR"];

async function setup(t, environment) {
Expand Down Expand Up @@ -62,6 +62,16 @@ test("history service honors Pi session-directory override", async t => {
assert.equal(page.items[0]?.id, id);
});

test("history service finds Kimi Code in KIMI_CODE_HOME and preserves its native prefixed id", async t => {
const { root, service } = await setup(t, root => ({ KIMI_CODE_HOME: root, KIMI_SHARE_DIR: join(root, "legacy") }));
const session = join(root, "sessions", "wd_project", `session_${id}`);
await mkdir(session, { recursive: true });
await writeFile(join(session, "state.json"), JSON.stringify({ id: `session_${id}`, version: 2, cwd: root, updatedAt: Date.now(), title: "Current chat", custom: {} }));
const page = await service.list("kimi");
assert.equal(page.error, undefined);
assert.equal(page.items[0]?.id, `session_${id}`);
});

test("history service finds Qwen's current projects and legacy tmp stores without duplicates", async t => {
const { root, service } = await setup(t, root => ({ QWEN_RUNTIME_DIR: root }));
await transcript(join(root, "projects", "workspace", "chats"), "qwen");
Expand Down Expand Up @@ -114,21 +124,21 @@ for (const [platform, home, expected] of [
["darwin", "/Users/Alice Smith", {
codex: "/Users/Alice Smith/.codex", grok: "/Users/Alice Smith/.grok",
opencode: "/Users/Alice Smith/.local/share/opencode", claude: "/Users/Alice Smith/.claude/projects",
qwen: ["/Users/Alice Smith/.qwen/projects", "/Users/Alice Smith/.qwen/tmp"], kimi: "/Users/Alice Smith/.kimi",
qwen: ["/Users/Alice Smith/.qwen/projects", "/Users/Alice Smith/.qwen/tmp"], kimi: "/Users/Alice Smith/.kimi", kimiCode: "/Users/Alice Smith/.kimi-code",
omp: "/Users/Alice Smith/.omp/agent/sessions", pi: "/Users/Alice Smith/.pi/agent/sessions",
cursor: "/Users/Alice Smith/.cursor", minimax: "/Users/Alice Smith/.minimax"
}],
["linux", "/home/runner", {
codex: "/home/runner/.codex", grok: "/home/runner/.grok",
opencode: "/home/runner/.local/share/opencode", claude: "/home/runner/.claude/projects",
qwen: ["/home/runner/.qwen/projects", "/home/runner/.qwen/tmp"], kimi: "/home/runner/.kimi",
qwen: ["/home/runner/.qwen/projects", "/home/runner/.qwen/tmp"], kimi: "/home/runner/.kimi", kimiCode: "/home/runner/.kimi-code",
omp: "/home/runner/.omp/agent/sessions", pi: "/home/runner/.pi/agent/sessions",
cursor: "/home/runner/.cursor", minimax: "/home/runner/.minimax"
}],
["win32", "C:\\Users\\Alice Smith", {
codex: "C:\\Users\\Alice Smith\\.codex", grok: "C:\\Users\\Alice Smith\\.grok",
opencode: "C:\\Users\\Alice Smith\\.local\\share\\opencode", claude: "C:\\Users\\Alice Smith\\.claude\\projects",
qwen: ["C:\\Users\\Alice Smith\\.qwen\\projects", "C:\\Users\\Alice Smith\\.qwen\\tmp"], kimi: "C:\\Users\\Alice Smith\\.kimi",
qwen: ["C:\\Users\\Alice Smith\\.qwen\\projects", "C:\\Users\\Alice Smith\\.qwen\\tmp"], kimi: "C:\\Users\\Alice Smith\\.kimi", kimiCode: "C:\\Users\\Alice Smith\\.kimi-code",
omp: "C:\\Users\\Alice Smith\\.omp\\agent\\sessions", pi: "C:\\Users\\Alice Smith\\.pi\\agent\\sessions",
cursor: "C:\\Users\\Alice Smith\\.cursor", minimax: "C:\\Users\\Alice Smith\\.minimax"
}]
Expand Down
Loading
Loading