diff --git a/plugins/copilot/neatcontext/README.md b/plugins/copilot/neatcontext/README.md index 71223e8..dd9c247 100644 --- a/plugins/copilot/neatcontext/README.md +++ b/plugins/copilot/neatcontext/README.md @@ -33,8 +33,8 @@ copilot plugin install neatcontext@neatcontext ## Commands - `/neatcontext:save [name]` — save reusable work from the visible conversation. -- `/neatcontext:use [name or number]` — connect or switch this workspace. -- `/neatcontext:disconnect` — disconnect only this workspace. +- `/neatcontext:use [name or number]` — connect or switch this session. +- `/neatcontext:disconnect` — disconnect only this session. - `/neatcontext:list` — list the contexts on this machine. - `/neatcontext:status` — show the selection and routing mode. - `/neatcontext:create` — create a context around an existing knowledge folder. @@ -47,9 +47,18 @@ copilot plugin install neatcontext@neatcontext - **Local Contexts.** The plugin stores Contexts on this machine. There is no NeatContext Desktop connection right now. -- **Selections are per workspace.** Copilot does not expose a session identity - to plugin processes, so a connected context belongs to the workspace folder: - every Copilot session opened in that folder shares it. +- **Selections are per session on Copilot CLI.** The command and MCP processes + use Copilot's shared session identity, so they agree even when they start in + different working directories. +- **Workspace fallback.** On a host that does not expose a session identity, + sessions share one selection only when the host starts both plugin processes + in the same working directory. +- **Upgrading.** Existing workspace-scoped selections are preserved. If the + current session has no selection, `/neatcontext:status` names an available + workspace selection so it can be reconnected for that session. +- **Session replacement.** Session identity is captured when a plugin process + starts. If a host reuses an MCP process for another session, restart that host + before reconnecting a context. - **Saving is explicit.** Run `/neatcontext:save` when the visible conversation contains durable work worth preserving. diff --git a/plugins/copilot/neatcontext/commands/disconnect.md b/plugins/copilot/neatcontext/commands/disconnect.md index 7ccc376..288327f 100644 --- a/plugins/copilot/neatcontext/commands/disconnect.md +++ b/plugins/copilot/neatcontext/commands/disconnect.md @@ -9,4 +9,5 @@ Disconnect the context currently connected to this session. !`node "${CLAUDE_PLUGIN_ROOT}/src/copilot/neatcontext-cli.mjs" disconnect` Relay the result verbatim. Do not run a status command afterward. This affects -only the current workspace; other workspaces keep their own connections. +only the current session. On hosts without a session identity, the selection is +shared by sessions opened in the same workspace. diff --git a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs index f051f2b..fadc671 100644 --- a/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs +++ b/plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs @@ -20,8 +20,12 @@ // Exit code is always 0: the output is meant to be read, not branched on. import { readFile, rm } from "node:fs/promises"; -import "./session.mjs"; -import { clearSelection, readSelection } from "../core/local-state.mjs"; +import { workspaceSessionId } from "./session.mjs"; +import { + clearSelection, + readSelection, + sessionSelectionFilePath +} from "../core/local-state.mjs"; import { createCapturedContext, createContext, @@ -138,6 +142,26 @@ async function loadState() { return { contexts, selection, connected }; } +async function workspaceSelectionHint(state) { + const workspace = workspaceSessionId(); + if (sessionId() === workspace) { + return null; + } + try { + const saved = JSON.parse(await readFile(sessionSelectionFilePath(workspace), "utf8")); + const context = state.contexts.find((candidate) => candidate.id === saved?.contextId); + if (!context) { + return null; + } + return ( + `An earlier version connected "${context.name}" to this workspace. ` + + `Reconnect it for this session with \`/neatcontext:use ${context.name}\`.` + ); + } catch { + return null; + } +} + async function commandStatus(state) { const { connected, selection } = state; const routing = await readRouting(); @@ -214,6 +238,11 @@ async function commandStatus(state) { "`/neatcontext:create`." : "No context is connected yet. Use `/neatcontext:use` to pick one." ); + const upgrade = await workspaceSelectionHint(state); + if (upgrade) { + print(""); + print(upgrade); + } reportMode(); } diff --git a/plugins/copilot/neatcontext/src/copilot/session.mjs b/plugins/copilot/neatcontext/src/copilot/session.mjs index d964e63..4dbe813 100644 --- a/plugins/copilot/neatcontext/src/copilot/session.mjs +++ b/plugins/copilot/neatcontext/src/copilot/session.mjs @@ -1,18 +1,18 @@ // GitHub Copilot host adapter for the reusable session-aware runtime. // -// Neither Copilot host hands this process a session id the way Claude Code -// does: Copilot CLI exposes no session identity to plugin processes, and the -// VS Code Agent Plugins preview does not document one for MCP servers. What -// both hosts do give every plugin process — the CLI the slash commands spawn -// and the MCP server alike — is the workspace as its working directory. +// Copilot CLI exposes the current session to both the command process and the +// MCP server as COPILOT_AGENT_SESSION_ID. Prefer it so both halves select the +// same context even when the host starts them in different working directories. // -// So a "session" here is the workspace: one selected context per workspace, -// shared by every Copilot session opened in it. The id is a stable digest of -// the normalized workspace path, so the CLI and the MCP server agree on it -// without ever talking to each other. +// The workspace digest remains the fallback for hosts that do not publish a +// session id. In that case all Copilot sessions opened in one workspace share +// the selection, as they did before. // -// NEATCONTEXT_SESSION_ID overrides the digest — for tests, and for any host -// that can inject a real per-session id into every plugin process. +// The host identity is captured when each process starts. This adapter does not +// try to detect a session replacement underneath a long-lived MCP server. +// +// NEATCONTEXT_SESSION_ID overrides both the host id and the digest — for tests, +// and for any host that can inject a real per-session id into every process. // // CLAUDE_CODE_SESSION_ID is deliberately NOT consulted, even though a // Claude-compat host might set it: a variable only some of this plugin's @@ -29,6 +29,13 @@ function explicitId(value) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +const SAFE_HOST_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/; + +function hostSessionId(value) { + const id = explicitId(value); + return id && SAFE_HOST_SESSION_ID.test(id) ? id : null; +} + export function workspaceSessionId(workspace = process.cwd()) { const resolved = path.resolve(workspace); // Windows paths compare case-insensitively; two spellings of one folder must @@ -39,7 +46,11 @@ export function workspaceSessionId(workspace = process.cwd()) { } export function copilotSessionId() { - return explicitId(process.env.NEATCONTEXT_SESSION_ID) ?? workspaceSessionId(); + return ( + explicitId(process.env.NEATCONTEXT_SESSION_ID) ?? + hostSessionId(process.env.COPILOT_AGENT_SESSION_ID) ?? + workspaceSessionId() + ); } configureSessionId(copilotSessionId); diff --git a/tests/copilot-plugin.test.mjs b/tests/copilot-plugin.test.mjs index e7e7734..70600ce 100644 --- a/tests/copilot-plugin.test.mjs +++ b/tests/copilot-plugin.test.mjs @@ -2,7 +2,8 @@ // reused verbatim, commands ported, and a local Context adapter in src/copilot. // These tests pin the fork's contracts: what must stay byte-identical to // claude-code, how -// sessions scope to workspaces on hosts that expose no session identity, and +// sessions use the identity Copilot exposes, fall back to workspaces when a +// host exposes none, and // that nothing here runs on its own. import assert from "node:assert/strict"; @@ -190,7 +191,10 @@ async function isolatedHome(prefix) { const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); return { directory, - env: { NEATCONTEXT_HOME: directory } + env: { + NEATCONTEXT_HOME: directory, + COPILOT_AGENT_SESSION_ID: "" + } }; } @@ -558,7 +562,11 @@ test("Copilot sessions scope to the workspace when no session id is provided", a const workspaceB = await mkdtemp(path.join(os.tmpdir(), "copilot-ws-b-")); // An empty override is "not set": the runs below must fall through to the // workspace digest even when the test runner's own environment carries ids. - const wsEnv = { ...home.env, NEATCONTEXT_SESSION_ID: "" }; + const wsEnv = { + ...home.env, + NEATCONTEXT_SESSION_ID: "", + COPILOT_AGENT_SESSION_ID: "" + }; await createContext(home, "workspace scoped"); @@ -573,6 +581,12 @@ test("Copilot sessions scope to the workspace when no session id is provided", a const sameWorkspace = await runNode(cli, ["status"], { env: wsEnv, cwd: workspaceA }); assert.match(sameWorkspace.stdout, /Connected context: workspace scoped/); + const unsafeHostId = await runNode(cli, ["status"], { + env: { ...wsEnv, COPILOT_AGENT_SESSION_ID: "../not-a-session" }, + cwd: workspaceA + }); + assert.match(unsafeHostId.stdout, /Connected context: workspace scoped/); + const otherWorkspace = await runNode(cli, ["status"], { env: wsEnv, cwd: workspaceB }); assert.match(otherWorkspace.stdout, /No context is connected yet/); @@ -582,6 +596,86 @@ test("Copilot sessions scope to the workspace when no session id is provided", a assert.match(modeB.stdout, /auto \(the default\)/); }); +test("Copilot CLI and MCP bridge share the host session across working directories", async (t) => { + const home = await isolatedHome("neatcontext-copilot-session-"); + const commandWorkspace = await mkdtemp(path.join(os.tmpdir(), "copilot-command-ws-")); + const bridgeWorkspace = await mkdtemp(path.join(os.tmpdir(), "copilot-bridge-ws-")); + const sessions = []; + t.after(async () => { + await Promise.all(sessions.map((session) => session.close())); + await Promise.all( + [home.directory, commandWorkspace, bridgeWorkspace].map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ); + }); + const env = { + ...home.env, + NEATCONTEXT_SESSION_ID: "", + COPILOT_AGENT_SESSION_ID: "copilot-agent-session-a" + }; + + await createContext(home, "shared session"); + const connect = await runNode(cli, ["use", "shared session"], { + env, + cwd: commandWorkspace + }); + assert.match(connect.stdout, /Connected the "shared session" context/); + + const session = rpcSession(env, { cwd: bridgeWorkspace }); + sessions.push(session); + await session.call(initialize(1)); + const grounded = await session.call(toolCall(2, "get_context")); + assert.match(grounded.result.content[0].text, /connected context: shared session/i); + + const otherSession = await runNode(cli, ["status"], { + env: { ...env, COPILOT_AGENT_SESSION_ID: "copilot-agent-session-b" }, + cwd: commandWorkspace + }); + assert.match(otherSession.stdout, /No context is connected yet/); +}); + +test("Copilot status offers an existing workspace selection after upgrading", async (t) => { + const home = await isolatedHome("neatcontext-copilot-upgrade-"); + const workspace = await mkdtemp(path.join(os.tmpdir(), "copilot-upgrade-ws-")); + t.after(async () => { + await Promise.all( + [home.directory, workspace].map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ); + }); + const workspaceEnv = { + ...home.env, + NEATCONTEXT_SESSION_ID: "", + COPILOT_AGENT_SESSION_ID: "" + }; + const sessionEnv = { + ...workspaceEnv, + COPILOT_AGENT_SESSION_ID: "copilot-agent-session-new" + }; + + await createContext(home, "upgrade target"); + await runNode(cli, ["use", "upgrade target"], { env: workspaceEnv, cwd: workspace }); + + const status = await runNode(cli, ["status"], { env: sessionEnv, cwd: workspace }); + assert.match(status.stdout, /No context is connected yet/); + assert.match(status.stdout, /earlier version connected "upgrade target"/); + assert.match(status.stdout, /\/neatcontext:use upgrade target/); + + await runNode(cli, ["use", "upgrade target"], { env: sessionEnv, cwd: workspace }); + const connected = await runNode(cli, ["status"], { env: sessionEnv, cwd: workspace }); + assert.match(connected.stdout, /Connected context: upgrade target/); + assert.doesNotMatch(connected.stdout, /earlier version connected/); + + await runNode(cli, ["delete", "upgrade target", "--yes"], { + env: sessionEnv, + cwd: workspace + }); + const missing = await runNode(cli, ["status"], { env: sessionEnv, cwd: workspace }); + assert.doesNotMatch(missing.stdout, /earlier version connected/); +}); + test("Copilot MCP bridge serves Contexts and routing locally", async (t) => { const home = await isolatedHome("neatcontext-copilot-mcp-"); const sessions = [];