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
65 changes: 38 additions & 27 deletions ts/packages/copilot-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,28 @@ pending prompts/unsupported interaction rather than pretending completion.

### Explicit binding, reconnect, and trust

NL and structured calls use the same conversation selection in every routing
mode. With no explicit ID, the first caller resolves the server default and
saves its **concrete conversation ID** under the plugin data directory's
`conversation-bindings` folder, keyed by server URL. Later hooks, MCP processes,
and reconnects reuse that ID even if the server default changes. Concurrent
first callers atomically adopt the same saved ID. Routing mode does not change
which conversation data is visible.

This is shared plugin/server context, not one conversation per Copilot chat:
stdio does not provide an intrinsic Copilot session identity. Sessions using the
same plugin data directory and server share the saved default, as NL callers
already shared the server default. Separate plugin data directories or explicit
IDs select separate context.
NL, direct/dev hooks, extension history, and structured calls share automatic
conversation selection **within a Copilot session**. The first caller resolves
the server default and saves its concrete ID, keyed by server URL and host
session ID. Later calls, reconnects, and resumed sessions retain that selection
even if the server default changes. Concurrent first callers atomically adopt
the same saved ID. A fresh Copilot session resolves the current server default,
not another session's saved selection. This does not create a private TypeAgent
conversation per chat: sessions resolving the same default still share its data.

Copilot CLI supplies `COPILOT_AGENT_SESSION_ID` to MCP children. Hooks use their
input `sessionId` instead of a potentially inherited environment value; extension
history uses the joined SDK session ID. Hosts without an MCP session ID share
automatic NL/structured selection only inside that MCP process. Cross-process
alignment on those hosts requires an explicit existing conversation ID.

Session bindings live in `conversation-bindings/sessions` under
`TYPEAGENT_PLUGIN_DATA`, or `~/.typeagent-copilot` by default. Unlike configuration
lookup, binding storage does not use `CLAUDE_PLUGIN_DATA`: Copilot injects that
variable into hooks but not MCP processes. Explicit `TYPEAGENT_PLUGIN_DATA`
isolates both configuration and bindings and must be consistent across callers.
Legacy server-only `conversation-bindings/*.json` defaults are ignored and left
untouched; they are not explicit user pins and must not bind unrelated new chats.

The two routes keep separate connections. Structured calls explicitly join the
selected ID with `structuredActions: {}` to obtain an independent owner; the
Expand All @@ -331,12 +340,14 @@ before changing selection, then start fresh sessions; pending work is not
automatically moved or replayed. Two fresh processes using the same public ID
still get isolated structured owners.

A missing/deleted conversation or an unreadable/corrupt saved binding is an
error, not a reason to silently choose a new default. To select another existing
conversation, configure its ID. To intentionally resolve the default again,
close sessions, remove only the matching server's saved binding file, and start
fresh sessions with no explicit ID. Configuration fields such as selected
skills are not rewritten when the default ID is saved.
A missing/deleted conversation or an unreadable/corrupt binding in the current
session is an error, not a reason to silently choose a new default. To select
another existing conversation, configure its ID and start a fresh session.
Without an explicit pin, starting a **new** Copilot session resolves the current
default; resuming the old session intentionally retains its selection and may
still fail. No request is automatically retried, and pending approvals, scopes,
and resume capabilities never move to another conversation. Configuration fields
such as selected skills are not rewritten when a default ID is saved.

The server's structured resume token is retained only in private volatile
connector memory. It is never logged, printed, persisted, put in config, or sent
Expand Down Expand Up @@ -829,14 +840,14 @@ The plugin stores config at `%USERPROFILE%\.typeagent-copilot\config.json` (Wind

**Environment variable overrides** (take precedence over config file):

| Variable | Default | Description |
| --------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ |
| `TYPEAGENT_MODE` | `direct` | `direct`, `mcp`, `dev`, or `bypass` |
| `TYPEAGENT_HOST` | `localhost` | TypeAgent server host |
| `TYPEAGENT_PORT` | `8999` | TypeAgent server port |
| `TYPEAGENT_CONVERSATION_ID` | Dedicated per-process conversation | Optional existing public conversation ID for structured tools; overrides config `conversationId` |
| `TYPEAGENT_PLUGIN_DATA` | `~/.typeagent-copilot` | Config directory |
| `TYPEAGENT_WORKSPACE_ROOTS` | Copilot process working directory | Approved roots for workspace MCP tools, separated by the platform path delimiter |
| Variable | Default | Description |
| --------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TYPEAGENT_MODE` | `direct` | `direct`, `mcp`, `dev`, or `bypass` |
| `TYPEAGENT_HOST` | `localhost` | TypeAgent server host |
| `TYPEAGENT_PORT` | `8999` | TypeAgent server port |
| `TYPEAGENT_CONVERSATION_ID` | Session-selected server default | Optional existing public conversation ID for all routes; overrides config `conversationId` and automatic selection |
| `TYPEAGENT_PLUGIN_DATA` | `~/.typeagent-copilot` | Config directory |
| `TYPEAGENT_WORKSPACE_ROOTS` | Copilot process working directory | Approved roots for workspace MCP tools, separated by the platform path delimiter |

---

Expand Down
29 changes: 20 additions & 9 deletions ts/packages/copilot-plugin/src/extension/session-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,21 @@ function resultText(result: unknown): string {
}

async function withDispatcher(
sessionId: string,
operation: (dispatcher: Dispatcher) => Promise<void>,
): Promise<void> {
const dispatcher = await connectToTypeAgent(createClientIO({}));
const dispatcher = await connectToTypeAgent(createClientIO({}), sessionId);
try {
await operation(dispatcher);
} finally {
await dispatcher.close();
}
}

async function insertToolHistory(event: ExtensionSessionEvent): Promise<void> {
async function insertToolHistory(
event: ExtensionSessionEvent,
sessionId: string,
): Promise<void> {
if (event.data.success !== true) return;
const toolName =
typeof event.data.toolName === "string"
Expand All @@ -89,7 +93,7 @@ async function insertToolHistory(event: ExtensionSessionEvent): Promise<void> {
source: "copilot-cli",
},
};
await withDispatcher(async (dispatcher) => {
await withDispatcher(sessionId, async (dispatcher) => {
await awaitCommand(
dispatcher,
`@history insert ${JSON.stringify(message)}`,
Expand All @@ -99,6 +103,7 @@ async function insertToolHistory(event: ExtensionSessionEvent): Promise<void> {

async function insertTurnHistory(
trace: RecordedInteractionTrace,
sessionId: string,
): Promise<void> {
if (
trace.toolCalls.some((tool) =>
Expand All @@ -116,7 +121,7 @@ async function insertTurnHistory(
source: "copilot-cli",
},
};
await withDispatcher(async (dispatcher) => {
await withDispatcher(sessionId, async (dispatcher) => {
await awaitCommand(
dispatcher,
`@history insert ${JSON.stringify(message)}`,
Expand Down Expand Up @@ -200,10 +205,13 @@ export class SessionCapture {
const metadata = this.toolMetadata.get(key);
this.toolMetadata.delete(key);
if (metadata) {
await this.dependencies.insertToolHistory({
...event,
data: { ...event.data, ...metadata },
});
await this.dependencies.insertToolHistory(
{
...event,
data: { ...event.data, ...metadata },
},
this.sessionId,
);
}
}
if (event.type === "session.idle") {
Expand Down Expand Up @@ -249,7 +257,10 @@ export class SessionCapture {
}
}
if (trace && !aborted) {
await this.dependencies.insertTurnHistory(trace);
await this.dependencies.insertTurnHistory(
trace,
this.sessionId,
);
writeDemoState({
event: "turnComplete",
turnId: makeTurnId(this.sessionId),
Expand Down
5 changes: 4 additions & 1 deletion ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ export async function handleDevActions(

try {
abortSignal?.throwIfAborted();
dispatcher = await dependencies.connectToTypeAgent(clientIO);
dispatcher = await dependencies.connectToTypeAgent(
clientIO,
input.sessionId,
);
abortSignal?.throwIfAborted();
submissionStarted = true;
const submitResult = await dispatcher.submitCommand(
Expand Down
5 changes: 4 additions & 1 deletion ts/packages/copilot-plugin/src/hooks/hook-direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ export async function handleDirect(
dependencies.emitProgress("Connecting to TypeAgent...", {
temporary: true,
});
dispatcher = await dependencies.connectToTypeAgent(clientIO);
dispatcher = await dependencies.connectToTypeAgent(
clientIO,
input.sessionId,
);
dependencies.emitProgress("Processing command...", {
temporary: true,
});
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/copilot-plugin/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const server =
: new TypeAgentMcpServer();

if (server instanceof TypeAgentMcpServer) {
// Stdio has no intrinsic Copilot session identity. This process retains one
// The host session selects public context; this process alone retains the
// private structured owner until shutdown, without cancelling pending work.
process.once("SIGINT", () => void server.close());
process.once("SIGTERM", () => void server.close());
Expand Down
52 changes: 41 additions & 11 deletions ts/packages/copilot-plugin/src/shared/conversation-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,36 @@
import { createHash, randomUUID } from "node:crypto";
import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import type {
AgentServerConnection,
ClientIO,
} from "@typeagent/agent-server-client";
import { getConfigDir, getConversationId } from "./plugin-config.js";
import { getConversationId } from "./plugin-config.js";

function bindingPath(url: string): string {
const processBindings = new Map<string, string>();

function bindingKey(url: string, sessionId: string | undefined): string {
const server = new URL(url).href;
const key = createHash("sha256").update(server).digest("hex");
return join(getConfigDir(), "conversation-bindings", `${key}.json`);
return createHash("sha256")
.update(JSON.stringify([server, sessionId]))
.digest("hex");
}

function bindingLocation(url: string, sessionId: string | undefined) {
// Copilot supplies CLAUDE_PLUGIN_DATA to hooks but not to MCP children.
const directory =
process.env.TYPEAGENT_PLUGIN_DATA ??
join(homedir(), ".typeagent-copilot");
if (sessionId !== undefined && !sessionId.trim()) {
throw new Error("TypeAgent conversation session ID must not be empty.");
}
return join(
directory,
"conversation-bindings",
"sessions",
`${bindingKey(url, sessionId)}.json`,
);
}

function hasCode(error: unknown, code: string): boolean {
Expand Down Expand Up @@ -51,22 +71,26 @@ async function readBinding(path: string): Promise<string | undefined> {
return requireId(value.conversationId);
}

/** Public context only, shared by hook/MCP processes using this config and server. */
/** Public context only; automatic selections belong to a host session, not a server. */
export async function readSelectedConversationId(
url: string,
sessionId: string | undefined = process.env.COPILOT_AGENT_SESSION_ID,
): Promise<string | undefined> {
const configured = getConversationId();
return configured === undefined
? readBinding(bindingPath(url))
: requireId(configured);
if (configured !== undefined) return requireId(configured);
const path = bindingLocation(url, sessionId);
return sessionId === undefined
? processBindings.get(path)
: readBinding(path);
}

export async function selectConversationId(
connection: AgentServerConnection,
clientIO: ClientIO,
url: string,
sessionId: string | undefined = process.env.COPILOT_AGENT_SESSION_ID,
): Promise<string> {
const selected = await readSelectedConversationId(url);
const selected = await readSelectedConversationId(url, sessionId);
if (selected !== undefined) return selected;

const joined = await connection.joinConversation(clientIO, {
Expand All @@ -75,7 +99,13 @@ export async function selectConversationId(
});
await connection.leaveConversation(joined.conversationId);
const conversationId = requireId(joined.conversationId);
const path = bindingPath(url);
const path = bindingLocation(url, sessionId);
if (sessionId === undefined) {
// Unknown hosts share NL/structured context only inside this process.
const winner = processBindings.get(path) ?? conversationId;
processBindings.set(path, winner);
return winner;
}
await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${randomUUID()}.tmp`;
await writeFile(temporary, JSON.stringify({ conversationId }), {
Expand All @@ -92,7 +122,7 @@ export async function selectConversationId(
} finally {
await unlink(temporary);
}
const winner = await readSelectedConversationId(url);
const winner = await readSelectedConversationId(url, sessionId);
if (winner === undefined) {
throw new Error(
"TypeAgent conversation binding disappeared during selection.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,23 @@ export function createStructuredActionClient(
connect?: StructuredActionClientOptions["connect"],
): StructuredActionClient {
const conversationId = getConversationId();
const sessionId = process.env.COPILOT_AGENT_SESSION_ID;
const clientIO = createClientIO({});
return new StructuredActionClient({
url: TYPEAGENT_URL,
clientIO,
...(connect === undefined ? {} : { connect }),
...(conversationId === undefined ? {} : { conversationId }),
resolveConversationId: (connection) =>
selectConversationId(connection, clientIO, TYPEAGENT_URL),
selectConversationId(
connection,
clientIO,
TYPEAGENT_URL,
sessionId,
),
validateConversationId: async (conversationId) => {
if (
(await readSelectedConversationId(TYPEAGENT_URL)) !==
(await readSelectedConversationId(TYPEAGENT_URL, sessionId)) !==
conversationId
) {
throw new StructuredActionClientError(
Expand Down
7 changes: 6 additions & 1 deletion ts/packages/copilot-plugin/src/shared/typeagent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,20 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO {
*/
export async function connectToTypeAgent(
clientIO: ClientIO,
sessionId: string | undefined = process.env.COPILOT_AGENT_SESSION_ID,
): Promise<Dispatcher> {
let conversationId = await readSelectedConversationId(TYPEAGENT_URL);
let conversationId = await readSelectedConversationId(
TYPEAGENT_URL,
sessionId,
);
if (conversationId === undefined) {
const connection = await connectToAgentServer();
try {
conversationId = await selectConversationId(
connection,
clientIO,
TYPEAGENT_URL,
sessionId,
);
} finally {
await connection.close();
Expand Down
Loading
Loading