Skip to content
Open
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
23 changes: 18 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,15 @@ external implementation runs are durable.
model-backed research request supplies its GitHub App token and Copilot
entitlement. The database stores only a token-free owner reference and durable
context.
- **Planner tools are repository-fixed.** The hosted runtime has no shell,
checkout, host filesystem, skills, plugins, or arbitrary GitHub access.
- **Planner tools are repository-fixed under `copilot-sdk` and `pi`.** Those
Planners have no shell, checkout, host filesystem, skills, plugins, or arbitrary
GitHub access. `HARNESS=atomic` deliberately runs every Planner session, local
or hosted, as a full Atomic session with the operator's Atomic tools and
resources, including shell and filesystem access as the server process's user.
Its working directory is a checkout verified from `invoke_planner` and
remembered per channel in memory, or else an empty per-channel directory. Its
summary and research workers stay isolated. There is no flag for this; the
harness is the choice.
- **Repository node IDs are authoritative.** Owner and repository names resolve
GitHub requests but never replace the stored node identity.
- **Persistence should precede publication.** Do not acknowledge or broadcast a
Expand All @@ -130,9 +137,13 @@ Planner destination without a mention.

`instruction()` strips the mention before model input. Recent room messages that
did not address the Planner still enter a bounded backscroll for the next turn.
An accepted comment also starts an explicit Planner turn after it commits.
An accepted comment also starts an explicit Planner turn after it commits. An MCP
`invoke_planner` call posts its instruction as the caller's member message and
runs under the channel's existing Planner owner; only a caller with a live
browser login can claim an unowned channel.

The Planner is a custom agent, not a general coding agent. Its turn runs
Under `copilot-sdk` and `pi` the Planner is a custom agent, not a general coding
agent; under `atomic` it is a full Atomic session. Either way its turn runs
through `HarnessAgent.stream()` from `@ai-sdk/harness`; the conversation stays
active until the returned stream finishes. An interrupted turn is never
replayed automatically because it may already have made durable tool changes.
Expand Down Expand Up @@ -398,7 +409,9 @@ names and the external GitHub MCP contribution. Counts vary with SDK and remote
MCP versions. A healthy boundary includes Chopin's document tools (currently
plan-named), repository tools, and allowed pull-request tools, and excludes
ambient capabilities such as `bash`, filesystem access, URL fetch, host Git,
issues, and unrestricted search.
issues, and unrestricted search. The exception is an `atomic` Planner session,
which deliberately adds Atomic's builtins and coding tools; its summary and
research worker sessions must still show only their own tools.

Treat a missing required tool or an unexpected ambient tool as a security or
configuration failure even when the overall count looks plausible.
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ appear as documents in navigation.
https://github.com/user-attachments/assets/72a85be8-685f-4d60-9937-b3855b46cebe

The Planner can inspect the selected GitHub repository and its pull requests
through bounded, read-only tools, then co-author the document. It cannot write to
GitHub, edit a checkout, or implement code. A separate coding agent can connect
through bounded, read-only tools, then co-author the document. Under the default
`copilot-sdk` harness, and under `pi`, it cannot write to GitHub, edit a checkout,
or implement code. `HARNESS=atomic` deliberately runs it as a full Atomic session
with shell and filesystem access as the server process's user; see
[Self-hosting](docs/self-hosting.md#choose-and-trust-a-harness). A separate coding agent can connect
to Chopin through MCP to create or revise documents and consume an approved
implementation graph.

Expand All @@ -60,8 +63,8 @@ and tool vocabulary remain optimized for planning.
- Chopin supports GitHub.com. GitHub Enterprise Server endpoints are not
configurable.
- The Planner's file, tree, and history tools read the default branch captured
when its session starts; code search is repository-scoped. It never reads a
local checkout or uncommitted changes.
when its session starts; code search is repository-scoped. Except under
`HARNESS=atomic`, it never reads a local checkout or uncommitted changes.
- Every browser participant signs in, passes the instance admission policy, and
needs repository access through the GitHub App installation. MCP callers also
pass instance admission, but use their own bearer token for repository
Expand Down
29 changes: 23 additions & 6 deletions apps/server/src/agent/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { COMPONENTS, DIFF_LANGUAGE, MERMAID_LANGUAGE } from "@chopin/dialect/dialect";

import type { Component } from "@chopin/dialect/dialect";
import type { PlannerWorkspace } from "../harness/atomic/workspace";

/** Components the agent writes itself. The rest are created for it. */
const AUTHORABLE = ["Callout", "Tabs", "Tab", "Underline"];
Expand Down Expand Up @@ -230,13 +231,29 @@ Questionnaires are created by \`ask\`, never by hand, and their answers are owne
elsewhere — leave them alone when you rewrite around them. To take one out of
the plan, use the \`detach_question\` operation rather than deleting the block.`;

export function plannerInstructions(repository: string, bootstrap?: string): string {
let access = `Read before you propose. The selected repository is ${repository}. Use
export function plannerInstructions(
repository: string,
bootstrap?: string,
workspace?: PlannerWorkspace,
): string {
let reading = `Read before you propose. The selected repository is ${repository}. Use
\`read_repository_file\`, \`list_repository_tree\`, \`search_repository\` and
\`repository_history\` for its code, and \`list_pull_requests\` and
\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository.

You have no shell, checkout, host filesystem, skills or repository instructions,
\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository.`;
if (!workspace) {
let isolated = `You have no shell, checkout, host filesystem, skills or repository instructions,
and cannot change GitHub. Ground the plan in what those reading tools return.`;
return [PROMPT, access, bootstrap].filter(Boolean).join("\n\n");
return [PROMPT, reading, isolated, bootstrap].filter(Boolean).join("\n\n");
}
let place = workspace.checkout
? `Your working directory, ${workspace.cwd}, is a local checkout of ${repository}
verified against its origin. Its branch and working tree may differ from what the
repository tools read.`
: `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created
empty for this document. It is not a checkout and holds no repository files, so read
${repository} through the repository tools.`;
let questions =
`\`ask_user_question\` and \`workflow\` questions appear to the document's members as
Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`;
return [PROMPT, reading, place, questions, bootstrap].filter(Boolean).join("\n\n");
}
20 changes: 20 additions & 0 deletions apps/server/src/auth/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ function grant(accessToken: string, refreshToken = "ghr_refresh"): GitHubTokenGr
}

describe("hosted login sessions", () => {
it("finds the most recent live process-local session for only the requested user", async () => {
let storage = new MemoryStorage();
let now = new Date("2026-08-13T12:00:00.000Z");
for (let id of ["U_first", "U_second"]) {
await storage.users.put({ id, login: id, avatarUrl: "", now });
}
let sessions = new Sessions(storage, false, () => now);
let first = await sessions.issue("U_first", grant("first-token"));
now = new Date(now.getTime() + 1_000);
let second = await sessions.issue("U_first", grant("newer-token"));
await sessions.issue("U_second", grant("other-user-token"));
expect((await sessions.forUser("U_first"))?.session.id).toBe(second.id);
expect(await sessions.forUser("U_unknown")).toBeUndefined();
expect(await new Sessions(storage, false, () => now).forUser("U_first")).toBeUndefined();
await sessions.revoke(request(pair(second.cookie)));
expect((await sessions.forUser("U_first"))?.session.id).toBe(first.id);
now = new Date(first.expiresAt);
expect(await sessions.forUser("U_first")).toBeUndefined();
});

it("stores only registry metadata while credentials remain process-local", async () => {
let storage = new MemoryStorage();
let now = new Date("2026-08-13T12:00:00.000Z");
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,16 @@ export class Sessions {
: undefined;
}

/** An MCP handoff may borrow an existing login to claim ownership, never the caller's bearer. */
async forUser(userId: string): Promise<AuthenticatedSession | undefined> {
for (let current of [...this.#sessions.values()].toReversed()) {
if (current.session.userId !== userId) continue;
let resolved = await this.resolve(current.session.id);
if (resolved) return resolved;
}
return undefined;
}

/** Read current credentials without triggering rotation from inside an active agent callback. */
async inspect(id: string): Promise<AuthenticatedSession | undefined> {
if (this.#refreshes.has(id) || this.#revocations.has(id)) return undefined;
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/chat/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ export function compose(
...backscroll.flatMap(said => said.references ?? []),
...references,
],
verbatim = false,
): string {
let asked = references.length > 0 ? text : instruction(text);
let asked = verbatim || references.length > 0 ? text : instruction(text);
let readableIds = new Set(catalogReferences.map(reference => reference.id));
let current = annotatedText(asked, references, readableIds);

Expand Down
Loading
Loading