Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/multi-root-workspace-dirs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Fix the extension ignoring the non-primary folders of a multi-root workspace: they are now passed to the session as additional directories, edits made in them are tracked in File Changes (and can be kept or undone), and a folder added to the workspace mid-conversation reaches the open session instead of waiting for a reload.
57 changes: 48 additions & 9 deletions apps/vscode/src/bridge-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export class BridgeHandler {
return vscode.workspace.workspaceFolders?.[0]?.uri ?? null;
}

/** Other multi-root workspace folders, passed to the session as ephemeral additionalDirs. */
private get additionalWorkspaceDirs(): readonly string[] {
return vscode.workspace.workspaceFolders?.slice(1).map((folder) => folder.uri.fsPath) ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track edits made under the added workspace roots

When the agent edits a file in one of these newly permitted secondary folders, captureFileBaseline rejects the absolute path because it requires every captured file to be contained by session.workDir. The edit therefore succeeds but never appears in the extension's File Changes list and cannot be restored through its keep/undo or fork-baseline flows. Extend baseline and file tracking to understand the additional workspace roots before exposing them as writable session directories.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f75815 — good catch, and it turned out to be three containment gates rather than one:

  • captureFileBaseline dropped these before BaselineManager ever saw them. Its resolver returns undefined outside workDir, so additional-root files are now resolved from the absolute path and checked against each root.
  • BaselineManager.resolveSessionFile rejected them too. Files under an additional root are keyed in the manifest by absolute path — a workDir-relative key would escape the root (../other/a.ts) and could collide with a same-named file in another root. paths.resolve(root, <absolute>) returns an absolute path unchanged, so the key round-trips on read and no manifest version bump is needed.
  • requireContainedRestorePath, the realpath-based symlink guard on undo, only knew workDir. It now takes the session's full root list and accepts a path contained by any of them, still resolving both sides through realpath so a symlink can't point a restore outside them. This one surfaced from the new test's undo assertion rather than from reading the code.

Added coverage for capture → File Changes → undo under a secondary root, plus a regression test that a path outside both the workspace and its additional roots is still rejected.

}

private getWorkDir(webviewId: string): string | null {
return this.customWorkDirs.get(webviewId) ?? this.workspaceRoot;
}
Expand Down Expand Up @@ -172,6 +177,7 @@ export class BridgeHandler {
model,
effort,
yoloMode: VSCodeSettings.yoloMode,
additionalDirs: this.additionalWorkspaceDirs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh additional roots for an already-open session

If a user adds another folder to the VS Code workspace after starting a conversation, subsequent prompts compute the updated additionalDirs here, but KimiRuntime.openSession returns the existing runtime through its same-session fast path and applySessionSettings does not apply this field. Because the extension also has no onDidChangeWorkspaceFolders handler, that conversation continues to reject the newly added root until it is detached or the window is reloaded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f75815 — confirmed exactly as described: applySessionSettings only touched permission flags, and openSession's same-session fast path returns the existing runtime without re-running create/resume, the only two places additionalDirs was sent.

applySessionSettings now also syncs additionalDirs, using the session's own addAdditionalDir(dir, { persist: false }) (the same call /add-dir uses, ephemeral to match how they're passed at create/resume) and skipping any the session already has, so it's a no-op on the create/resume paths where they were just sent.

I went this route rather than adding an onDidChangeWorkspaceFolders handler since every prompt already recomputes additionalDirs and flows through openSession — this makes that existing path actually apply them, without a second source of truth. Happy to add the event handler too if you'd prefer folders to propagate the moment they're added rather than on the next prompt.

Covered by two tests: a folder added mid-conversation reaching the open session, and no re-add for one it already has.

...(sessionId === undefined ? {} : { sessionId }),
});
this.fileManager.setSession(webviewId, baselineSession(runtime));
Expand All @@ -181,7 +187,11 @@ export class BridgeHandler {
const current = this.runtime.getSession(sessionId);
const session =
current?.session ??
(await this.runtime.harness.resumeSession({ id: sessionId, includeSubagents: true }));
(await this.runtime.harness.resumeSession({
id: sessionId,
includeSubagents: true,
additionalDirs: this.additionalWorkspaceDirs,
}));
if (!areSameFsPath(session.workDir, this.requireWorkDir(webviewId))) {
if (current === undefined) {
await session.close().catch((error: unknown) => {
Expand Down Expand Up @@ -241,6 +251,33 @@ export class BridgeHandler {
: `@${mentionTarget}:${selection.start.line + 1}-${selection.end.line + 1}`;
}

/**
* The file's URI if the session may write to it: under its working
* directory, or under one of its additionalDirs (other multi-root workspace
* folders, /add-dir). Additional roots sit outside workDir, where the
* workDir-relative resolver returns undefined, so those are resolved from
* the absolute path instead. Edits there used to be dropped, never reaching
* File Changes and impossible to keep or undo.
*/
private resolveTrackablePath(
session: BaselineSession,
workDirUri: vscode.Uri,
filePath: string,
): vscode.Uri | undefined {
const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath);
if (resolved !== undefined) {
return isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true })
? resolved.uri
: undefined;
}
if (!path.isAbsolute(filePath) && !path.win32.isAbsolute(filePath)) return undefined;
const candidate = vscode.Uri.file(filePath);
const underAdditionalDir = (session.additionalDirs ?? []).some((dir) =>
isWorkspacePathContainedSync(vscode.Uri.file(dir), candidate, { allowMissing: true }),
);
return underAdditionalDir ? candidate : undefined;
}

captureFileBaseline(
session: BaselineSession,
filePath: string,
Expand All @@ -262,24 +299,22 @@ export class BridgeHandler {
return;
}

const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath);
if (
resolved === undefined ||
!isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true })
) {
const resolvedUri = this.resolveTrackablePath(session, workDirUri, filePath);
if (resolvedUri === undefined) {
this.logRuntimeError(
"Unable to capture a file baseline",
new Error("File is outside the session working directory"),
);
return;
}

const capture = this.baselineManager.capture(session, resolved.uri.fsPath);
const capture = this.baselineManager.capture(session, resolvedUri.fsPath);

void capture
.then(async () => {
await Promise.all(
webviewIds.map(async (webviewId) => {
this.fileManager.trackFile(webviewId, resolved.uri.fsPath);
this.fileManager.trackFile(webviewId, resolvedUri.fsPath);
await this.fileManager.refreshChanges(webviewId);
}),
);
Expand Down Expand Up @@ -331,14 +366,18 @@ function baselineSession(runtime: SessionRuntime): BaselineSession {
id: runtime.id,
workDir: runtime.session.workDir,
metadata: runtime.summary?.metadata,
additionalDirs: runtime.summary?.additionalDirs,
});
}

function baselineSummary(summary: Pick<BaselineSession, "id" | "workDir" | "metadata">): BaselineSession {
function baselineSummary(
summary: Pick<BaselineSession, "id" | "workDir" | "metadata" | "additionalDirs">,
): BaselineSession {
return {
id: summary.id,
workDir: summary.workDir,
...(summary.metadata === undefined ? {} : { metadata: summary.metadata }),
...(summary.additionalDirs === undefined ? {} : { additionalDirs: summary.additionalDirs }),
};
}

Expand Down
13 changes: 11 additions & 2 deletions apps/vscode/src/handlers/session.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,17 @@ function toSessionInfo(summary: SessionSummary): SessionInfo {
};
}

function baselineSession(summary: Pick<SessionSummary, "id" | "workDir" | "metadata">): BaselineSession {
return { id: summary.id, workDir: summary.workDir, metadata: summary.metadata };
function baselineSession(
summary: Pick<SessionSummary, "id" | "workDir" | "metadata"> & {
readonly additionalDirs?: readonly string[];
},
): BaselineSession {
return {
id: summary.id,
workDir: summary.workDir,
metadata: summary.metadata,
additionalDirs: summary.additionalDirs,
};
}

function isInsideOrEqual(root: string, candidate: string): boolean {
Expand Down
57 changes: 49 additions & 8 deletions apps/vscode/src/managers/baseline.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface BaselineSession {
readonly id: string;
readonly workDir: string;
readonly metadata?: Readonly<Record<string, unknown>>;
/**
* Extra roots the session may write to (multi-root workspace folders,
* /add-dir). Files under these are tracked too, keyed in the manifest by
* their absolute path rather than a workDir-relative one.
*/
readonly additionalDirs?: readonly string[];
}

interface ManifestEntry {
Expand Down Expand Up @@ -186,7 +192,7 @@ export class BaselineManager {
`No baseline exists for "${resolved.relativePath}" in session "${session.id}"`,
);
}
await restoreFile(session.workDir, resolved.absolutePath, baseline);
await restoreFile(sessionRoots(session), resolved.absolutePath, baseline);
});
}

Expand All @@ -198,7 +204,7 @@ export class BaselineManager {
const baseline = await this.readEffectiveBaseline(session, relativePath, manifest);
if (baseline === undefined) continue;
await restoreFile(
session.workDir,
sessionRoots(session),
resolveSessionFile(session, relativePath).absolutePath,
baseline,
);
Expand Down Expand Up @@ -654,6 +660,14 @@ function resolveSessionFile(session: BaselineSession, filePath: string): Resolve
relativePath.startsWith(parentPrefix) ||
paths.isAbsolute(relativePath)
) {
// Outside workDir, but the session may still legitimately write here.
// Keyed by absolute path: a workDir-relative key would escape the root
// (`../other/a.ts`) and could collide with a same-named file in another
// root. `paths.resolve(root, <absolute>)` returns it unchanged, so the
// key round-trips back through this function on read.
if (isUnderAdditionalDir(session, paths, absolutePath)) {
return { absolutePath, relativePath: absolutePath };
}
throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`);
}

Expand All @@ -663,6 +677,23 @@ function resolveSessionFile(session: BaselineSession, filePath: string): Resolve
};
}

function isUnderAdditionalDir(
session: BaselineSession,
paths: path.PlatformPath,
absolutePath: string,
): boolean {
return (session.additionalDirs ?? []).some((dir) => {
const dirRoot = paths.resolve(dir);
const relative = paths.relative(dirRoot, absolutePath);
return (
relative.length > 0 &&
relative !== '..' &&
!relative.startsWith(`..${paths.sep}`) &&
!paths.isAbsolute(relative)
);
});
}

function isWindowsAbsolute(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value);
}
Expand Down Expand Up @@ -747,11 +778,11 @@ async function readCurrentFile(absolutePath: string): Promise<string | undefined
}

async function restoreFile(
workDir: string,
roots: readonly string[],
absolutePath: string,
baseline: BaselineValue,
): Promise<void> {
await requireContainedRestorePath(workDir, absolutePath);
await requireContainedRestorePath(roots, absolutePath);
if (!baseline.existedBefore) {
try {
await unlink(absolutePath);
Expand All @@ -773,13 +804,18 @@ async function restoreFile(
}
}

async function requireContainedRestorePath(workDir: string, absolutePath: string): Promise<void> {
async function requireContainedRestorePath(
roots: readonly string[],
absolutePath: string,
): Promise<void> {
try {
const [realWorkDir, realTarget] = await Promise.all([
realpath(workDir),
// Resolved through realpath on both sides so a symlink cannot point the
// restore outside the session's own roots.
const [realRoots, realTarget] = await Promise.all([
Promise.all(roots.map(async (root) => realpath(root))),
realExistingPath(absolutePath),
]);
if (relativeFsPath(realWorkDir, realTarget) === undefined) {
if (realRoots.every((root) => relativeFsPath(root, realTarget) === undefined)) {
throw new BaselineError(`Refusing to restore path outside the session workspace: "${absolutePath}"`);
}
} catch (error) {
Expand All @@ -788,6 +824,11 @@ async function requireContainedRestorePath(workDir: string, absolutePath: string
}
}

/** The session's writable roots: its working directory plus any additionalDirs. */
function sessionRoots(session: BaselineSession): readonly string[] {
return [session.workDir, ...(session.additionalDirs ?? [])];
}

async function realExistingPath(candidate: string): Promise<string> {
let current = candidate;
while (true) {
Expand Down
32 changes: 31 additions & 1 deletion apps/vscode/src/runtime/kimi-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface OpenSessionOptions {
readonly model: string;
readonly effort: string;
readonly yoloMode: boolean;
/**
* Other multi-root workspace folders (ephemeral, per-session). Must be
* resent on every create/resume: the engine's additional-dirs state is not
* persisted across process restarts.
*/
readonly additionalDirs?: readonly string[];
}

/** Extension-host owner for one in-process Node SDK harness. */
Expand Down Expand Up @@ -115,8 +121,13 @@ export class KimiRuntime {
thinking: normalizeEffort(options.effort),
permission: corePermissionForLegacyApproval(defaultApproval),
metadata: legacyApprovalMetadata(defaultApproval),
additionalDirs: options.additionalDirs,
})
: await this.harness.resumeSession({ id: requestedId, includeSubagents: true });
: await this.harness.resumeSession({
id: requestedId,
includeSubagents: true,
additionalDirs: options.additionalDirs,
});
try {
assertSessionWorkDir(session, options.workDir);
const storedApproval = readLegacyApprovalFlags(session.summary?.metadata);
Expand Down Expand Up @@ -274,6 +285,25 @@ async function applySessionSettings(
if (status.permission !== permission) {
await session.setPermission(permission);
}
await syncAdditionalDirs(session, options.additionalDirs);
}

/**
* Folders added to the workspace after a session opened never reach it
* otherwise: openSession's same-session fast path returns the existing runtime
* without re-running create/resume, the only two places additionalDirs is sent.
* Ephemeral, matching how they are passed at create/resume.
*/
async function syncAdditionalDirs(
session: Session,
additionalDirs: readonly string[] | undefined,
): Promise<void> {
if (additionalDirs === undefined || additionalDirs.length === 0) return;
const existing = session.summary?.additionalDirs ?? [];
for (const dir of additionalDirs) {
if (existing.some((current) => areSameFsPath(current, dir))) continue;
await session.addAdditionalDir(dir, { persist: false });
}
}

export function normalizeEffort(effort: string): ThinkingEffort {
Expand Down
29 changes: 29 additions & 0 deletions apps/vscode/test/baseline.manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,35 @@ describe('baseline boundaries (errors, cleanup, and platform paths)', () => {
);
});

it('tracks a file under an additional workspace root', async () => {
// Multi-root workspace: the agent may write to secondary folders, so an
// edit there has to be captured, listed, and undoable like any other.
const secondRoot = join(root, 'second-root');
await mkdir(secondRoot, { recursive: true });
const filePath = join(secondRoot, 'lib.ts');
await writeFile(filePath, 'original\n', 'utf-8');
const session: BaselineSession = { ...createSession(), additionalDirs: [secondRoot] };

await manager.capture(session, filePath);
await writeFile(filePath, 'edited\n', 'utf-8');

const changes = await manager.getChanges(session);
expect(changes.map((change) => change.path)).toEqual([filePath]);

await manager.undo(session, filePath);
await expect(readFile(filePath, 'utf-8')).resolves.toBe('original\n');
});

it('still rejects a path outside both the workspace and its additional roots', async () => {
const secondRoot = join(root, 'second-root');
await mkdir(secondRoot, { recursive: true });
const session: BaselineSession = { ...createSession(), additionalDirs: [secondRoot] };

await expect(manager.capture(session, join(root, 'elsewhere.ts'))).rejects.toThrow(
'is outside workspace',
);
});

it('normalizes Windows drive case for an in-workspace baseline', async () => {
const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' };
await manager.capture(session, 'C:\\Workspace\\src\\new.ts');
Expand Down
23 changes: 23 additions & 0 deletions apps/vscode/test/bridge-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,29 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () =>
await vi.waitFor(() => expect(showLogs).toHaveBeenCalledOnce());
});

it("passes the other multi-root workspace folders as additionalDirs when resuming a session", async () => {
const secondRoot = await mkdtemp(join(tmpdir(), "kimi-vscode-bridge-root2-"));
host.workspaceFolders.push({ uri: new host.Uri(secondRoot) });
const session = createResumedSession("session-1", root);
host.harness.resumeSession.mockResolvedValueOnce(session as never);

await bridge.handle(
{
id: "rpc-1",
method: Methods.LoadKimiSessionHistory,
params: { kimiSessionId: "session-1" },
},
"view-1",
);

expect(host.harness.resumeSession).toHaveBeenCalledWith({
id: "session-1",
includeSubagents: true,
additionalDirs: [secondRoot],
});
await rm(secondRoot, { recursive: true, force: true });
});

it("returns a readable error when persisted session state is corrupt without wedging the bridge", async () => {
host.harness.resumeSession.mockRejectedValueOnce(
new Error("Session state is invalid JSON at line 4"),
Expand Down
Loading