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
19 changes: 14 additions & 5 deletions plugins/copilot/neatcontext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion plugins/copilot/neatcontext/commands/disconnect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 31 additions & 2 deletions plugins/copilot/neatcontext/src/copilot/neatcontext-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}

Expand Down
35 changes: 23 additions & 12 deletions plugins/copilot/neatcontext/src/copilot/session.mjs
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
mazong1123 marked this conversation as resolved.
//
// 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
Expand All @@ -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
Expand All @@ -39,7 +46,11 @@ export function workspaceSessionId(workspace = process.cwd()) {
}

export function copilotSessionId() {
return explicitId(process.env.NEATCONTEXT_SESSION_ID) ?? workspaceSessionId();
return (
Comment thread
mazong1123 marked this conversation as resolved.
explicitId(process.env.NEATCONTEXT_SESSION_ID) ??
hostSessionId(process.env.COPILOT_AGENT_SESSION_ID) ??
workspaceSessionId()
);
}

configureSessionId(copilotSessionId);
100 changes: 97 additions & 3 deletions tests/copilot-plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -190,7 +191,10 @@ async function isolatedHome(prefix) {
const directory = await mkdtemp(path.join(os.tmpdir(), prefix));
return {
directory,
env: { NEATCONTEXT_HOME: directory }
env: {
Comment thread
mazong1123 marked this conversation as resolved.
NEATCONTEXT_HOME: directory,
COPILOT_AGENT_SESSION_ID: ""
}
};
}

Expand Down Expand Up @@ -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");

Expand All @@ -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/);

Expand All @@ -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) => {
Comment thread
mazong1123 marked this conversation as resolved.
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 = [];
Expand Down