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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ jobs:
packages/ui/hooks/useAnnotationDraft.seam.test.tsx
packages/ui/hooks/useExternalAnnotations.seam.test.tsx
packages/ui/hooks/useAIChat.seam.test.tsx
packages/ui/hooks/useAIChat.forkOrigin.test.tsx
packages/ui/hooks/useOriginFork.test.tsx
packages/ui/hooks/useFileBrowser.seam.test.tsx
packages/ui/hooks/usePlanDiff.test.tsx
packages/ui/hooks/useLinkedDoc.test.tsx
Expand Down
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ Ask AI providers are detected independently from installed/authenticated local C

Automatic resolution is session-only and never writes a preference. Explicit per-origin choices are persisted in cookies, so a user can override the automatic match for one agent without changing the default for another.

> **Origin-session forking (#1519):** only `claude-code` and `opencode` are ever live `originFork` agents — they're the two providers that declare `capabilities.fork: true` (`packages/ai/providers/claude-agent-sdk.ts`, `.../opencode-sdk.ts`; `codex-sdk` and `pi-sdk` both declare `fork: false`). `/api/ai/capabilities`'s `originFork.providerIds` is computed server-side by matching each registered provider's registry **id or type name** against this same table's provider types for `originFork.agent` (`matchesAgentProvider` in `packages/core/agents.ts`, used by `originForkProviderIds` in `packages/ai/endpoints.ts`) — instance ids can be custom, so id-only matching would silently miss one; this is the same rule the client uses to pick a default provider for an origin (`findOriginAIProvider` in `packages/ui/utils/aiProvider.ts`). The client (`useOriginFork` in `packages/ui/hooks/useOriginFork.ts`) shows the "Fork the <agent> session" toggle only when the *effective* provider id (explicit selection if it resolves to a real, current provider, else the server's `defaultProvider` — never array order) is in that list, and posts `forkOrigin: true` on `/api/ai/session` when armed.

> **Codex transport note:** the `codex-sdk` provider id is a stable identifier only — it no longer uses `@openai/codex-sdk` / `codex exec`. It drives a long-lived `codex app-server` process over JSON-RPC (`packages/ai/providers/codex-app-server.ts`), which respects the user's/enterprise-managed approval policy and supports interactive Allow/Deny approvals. The id stays `codex-sdk` to preserve saved cookie preferences, the `agents.ts` mapping, and the UI reasoning-effort gate.

> **OpenCode transport note:** the `opencode-sdk` provider spawns its own `opencode serve` per process on an OS-assigned port (`port: 0`) and never attaches to a server it did not spawn (an attached server can't be cleaned up by us, and opencode's per-directory instances accumulate in it without eviction). The spawned server is closed on dispose and on process exit. Model discovery is deferred behind the provider initializer (`?activate=` from the model picker, or the first opencode session) exactly like Codex — nothing spawns at server boot, so the picker lists opencode with an empty model list until first activation. Regression-pinned by `packages/ai/providers/opencode-sdk.test.ts`.
Expand Down Expand Up @@ -590,8 +592,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/editor-annotations` | GET | List editor annotations (VS Code only) |
| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) |
| `/api/ai/capabilities` | GET | Check if AI features are available |
| `/api/ai/session` | POST | Create or fork an AI session |
| `/api/ai/capabilities` | GET | Check if AI features are available. Also reports `originFork: { agent, providerIds } \| null` (#1519): when this launch knows the agent session that invoked it, `agent` names its harness and `providerIds` are the registry ids of providers that can fork it (`capabilities.fork` AND natively own that harness — see "Ask AI Provider Defaults"); `null` when there's no known origin session |
| `/api/ai/session` | POST | Create or fork an AI session (body may set `forkOrigin: true` to fork the session named by `originFork` — for that path the server supplies the actual `ParentSession` itself, so the client never needs to construct one) |
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
| `/api/ai/abort` | POST | Abort the current query |
| `/api/ai/permission` | POST | Respond to a permission request |
Expand Down Expand Up @@ -624,8 +626,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/editor-annotations` | GET | List editor annotations (VS Code only) |
| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) |
| `/api/ai/capabilities` | GET | Check if AI features are available |
| `/api/ai/session` | POST | Create or fork an AI session |
| `/api/ai/capabilities` | GET | Check if AI features are available. Also reports `originFork: { agent, providerIds } \| null` (#1519): when this launch knows the agent session that invoked it, `agent` names its harness and `providerIds` are the registry ids of providers that can fork it (`capabilities.fork` AND natively own that harness — see "Ask AI Provider Defaults"); `null` when there's no known origin session |
| `/api/ai/session` | POST | Create or fork an AI session (body may set `forkOrigin: true` to fork the session named by `originFork` — for that path the server supplies the actual `ParentSession` itself, so the client never needs to construct one) |
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
| `/api/ai/abort` | POST | Abort the current query |
| `/api/ai/permission` | POST | Respond to a permission request |
Expand Down Expand Up @@ -686,8 +688,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/annotate/client-lease` | GET (SSE) | Client lease for local direct structured gates: each open stream is one connected review surface. 404 when the capability is not advertised. |
| `/api/agent-terminal/pty/<token>` | WebSocket | Tokenized PTY bridge for the optional annotate-mode agent terminal |
| `/api/ai/capabilities` | GET | Check if AI features are available |
| `/api/ai/session` | POST | Create or fork an AI session |
| `/api/ai/capabilities` | GET | Check if AI features are available. Also reports `originFork: { agent, providerIds } \| null` (#1519): when this launch knows the agent session that invoked it, `agent` names its harness and `providerIds` are the registry ids of providers that can fork it (`capabilities.fork` AND natively own that harness — see "Ask AI Provider Defaults"); `null` when there's no known origin session |
| `/api/ai/session` | POST | Create or fork an AI session (body may set `forkOrigin: true` to fork the session named by `originFork` — for that path the server supplies the actual `ParentSession` itself, so the client never needs to construct one) |
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
| `/api/ai/abort` | POST | Abort the current query |
| `/api/ai/permission` | POST | Respond to a permission request |
Expand Down
106 changes: 104 additions & 2 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
import {
startPlannotatorServer,
handleServerReady,
buildOriginSession,
type ParentSession,
} from "@plannotator/server";
import {
startReviewServer,
Expand Down Expand Up @@ -343,6 +345,19 @@ const staticFlagIdx = args.indexOf("--static");
const staticFlag = staticFlagIdx !== -1;
if (staticFlag) args.splice(staticFlagIdx, 1);

// Value flag: --session-id <id> (annotate only) — lets a caller name the
// invoking agent session explicitly, the same way opencode-annotate-last's
// stdin JSON does. Exists for the OpenCode CLI-bridge fallback leg of
// /plannotator-annotate (buildAnnotateCliArgs), which has no stdin channel:
// it shells out to the plain `annotate` subcommand, so this flag is the only
// way that leg can offer Ask AI fork-origin (#1519).
const sessionIdIdx = args.indexOf("--session-id");
let sessionIdFlag: string | undefined;
if (sessionIdIdx !== -1 && args[sessionIdIdx + 1]) {
sessionIdFlag = args[sessionIdIdx + 1];
args.splice(sessionIdIdx, 2);
}

// Stdout matrix for annotate / annotate-last / copilot annotate-last.
//
// --hook (recommended for hooks):
Expand Down Expand Up @@ -597,6 +612,49 @@ const detectedOrigin: Origin =
process.env.OMPCODE ? "oh-my-pi" :
"claude-code";

/**
* Best-effort identity of the invoking Claude Code session, for surfaces
* launched from inside one (a slash command's `!` bang runs this CLI as a
* descendant of the agent's shell, so the ancestor-PID walk finds it). Lets
* Ask AI offer to fork that session instead of starting fresh (#1519).
* Server-side only — never crosses the wire to the browser. Only
* claude-code and opencode providers can fork a session today, so this is
* only attempted when detectedOrigin is "claude-code"; a miss returns null,
* which just means the toggle isn't offered.
*
* cwd follows the same PLANNOTATOR_CWD idiom used everywhere else in this
* file (the original working directory before a launcher shim `cd`s) rather
* than a bare `process.cwd()` — claude-agent-sdk.ts forks with `cwd:
* parent.cwd`, so a wrong cwd here roots the forked session in the wrong
* directory.
*/
function resolveInvokingClaudeSession(): ParentSession | null {
if (detectedOrigin !== "claude-code") return null;
try {
const logPath = resolveSessionLogByAncestorPids();
if (!logPath) return null;
return buildOriginSession({
agent: "claude-code",
sessionId: path.basename(logPath, ".jsonl"),
cwd: process.env.PLANNOTATOR_CWD || process.cwd(),
});
} catch {
return null;
}
}

/**
* Build the Ask AI fork-origin ParentSession for an OpenCode bridge call
* (opencode-plan/opencode-review/opencode-annotate-last), from the
* sessionId/directory pair each bridge stdin payload carries. Thin wrapper
* around the shared builder (`buildOriginSession`, packages/server) that
* also backs the OpenCode plugin's own `toOriginSession` — the one place
* the {sessionId, cwd, agent} object actually gets constructed (#1519).
*/
function toOpenCodeOriginSession(input: { sessionId?: unknown; directory?: unknown }): ParentSession | null {
return buildOriginSession({ agent: "opencode", sessionId: input.sessionId, cwd: input.directory });
}

type OpenCodeBridgeAgent = {
name: string;
description?: string;
Expand Down Expand Up @@ -1153,6 +1211,7 @@ if (args[0] === "sessions") {
gitRef,
error: diffError,
origin: detectedOrigin,
originSession: resolveInvokingClaudeSession(),
project: reviewProject,
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined,
gitContext,
Expand Down Expand Up @@ -1382,6 +1441,13 @@ if (args[0] === "sessions") {
markdown,
filePath: absolutePath,
origin: detectedOrigin,
// An explicit --session-id (the OpenCode CLI-bridge fallback leg of
// /plannotator-annotate, which has no stdin channel — see the flag's
// definition above) takes priority; otherwise fall back to the
// ancestor-PID resolution used by a direct Claude Code invocation.
originSession: sessionIdFlag
? buildOriginSession({ agent: detectedOrigin, sessionId: sessionIdFlag, cwd: projectRoot })
: resolveInvokingClaudeSession(),
mode: liveAppResolved ? "annotate-app" : annotateMode,
liveApp: liveAppResolved
? {
Expand Down Expand Up @@ -1480,6 +1546,10 @@ if (args[0] === "sessions") {
const RECENT_MESSAGES_LIMIT = 25;
let lastMessage: RenderedMessage | null = null;
let recentMessages: RenderedMessage[] = [];
// Claude Code path only: the transcript the message was actually read
// from (whichever candidate strategy hit), so its basename (<sessionId>.jsonl)
// can seed Ask AI session forking (#1519).
let claudeSessionLogPath: string | null = null;

// Copilot CLI sets no env fingerprint, so detection matches ancestor pids
// against session-state inuse locks (spawns ps). Only attempted when no
Expand Down Expand Up @@ -1601,6 +1671,7 @@ if (args[0] === "sessions") {
if (recent.length > 0) {
recentMessages = recent;
lastMessage = recent[0];
claudeSessionLogPath = logPath;
return;
}
}
Expand Down Expand Up @@ -1641,10 +1712,25 @@ if (args[0] === "sessions") {
? recentMessages.map((m) => ({ messageId: m.messageId, text: m.text, timestamp: m.timestamp }))
: undefined;

// The session the annotated message came from — lets Ask AI offer to fork
// it (#1519). Only claimed when the harness is genuinely Claude Code and
// the transcript that produced `annotatedMessage` was actually resolved
// (its basename is the session id); no other harness's provider can fork
// today.
const annotateLastOriginSession: ParentSession | null =
detectedOrigin === "claude-code" && claudeSessionLogPath
? buildOriginSession({
agent: "claude-code",
sessionId: path.basename(claudeSessionLogPath, ".jsonl"),
cwd: projectRoot,
})
: null;

const server = await startAnnotateServer({
markdown: annotatedMessage.text,
filePath: "last-message",
origin: copilotDetected ? "copilot-cli" : detectedOrigin,
originSession: annotateLastOriginSession,
mode: "annotate-last",
sharingEnabled,
shareBaseUrl,
Expand Down Expand Up @@ -1752,7 +1838,7 @@ if (args[0] === "sessions") {
// that cannot import Bun-only server modules directly.

const inputJson = await Bun.stdin.text();
const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown }>(
const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown; sessionId?: unknown; directory?: unknown }>(
"opencode-plan",
inputJson,
);
Expand All @@ -1776,6 +1862,7 @@ if (args[0] === "sessions") {
const server = await startPlannotatorServer({
plan: planContent,
origin: "opencode",
originSession: toOpenCodeOriginSession(input),
sharingEnabled: bridgeSharingEnabled,
shareBaseUrl: bridgeShareBaseUrl,
pasteApiUrl: bridgePasteApiUrl,
Expand Down Expand Up @@ -1832,7 +1919,7 @@ if (args[0] === "sessions") {
// in a host that cannot import Bun-only server modules directly.

const inputJson = await Bun.stdin.text();
const input = parseOpenCodeBridgeInput<{ arguments?: unknown; supportsApprovalNotes?: unknown }>(
const input = parseOpenCodeBridgeInput<{ arguments?: unknown; supportsApprovalNotes?: unknown; sessionId?: unknown; directory?: unknown }>(
"opencode-review",
inputJson,
);
Expand Down Expand Up @@ -1964,6 +2051,7 @@ if (args[0] === "sessions") {
gitRef,
error: diffError,
origin: "opencode",
originSession: toOpenCodeOriginSession(input),
project: reviewProject,
diffType: isPRMode ? undefined : userDiffType,
gitContext,
Expand Down Expand Up @@ -2031,6 +2119,8 @@ if (args[0] === "sessions") {
const input = parseOpenCodeBridgeInput<{
gate?: unknown;
recentMessages?: unknown;
sessionId?: unknown;
directory?: unknown;
}>("opencode-annotate-last", inputJson);

const recentMessages = Array.isArray(input.recentMessages)
Expand Down Expand Up @@ -2068,6 +2158,7 @@ if (args[0] === "sessions") {
markdown: lastMessage.text,
filePath: "last-message",
origin: "opencode",
originSession: toOpenCodeOriginSession(input),
mode: "annotate-last",
recentMessages: pickerMessages,
sharingEnabled: bridgeSharingEnabled,
Expand Down Expand Up @@ -2459,10 +2550,21 @@ if (args[0] === "sessions") {

const planProject = (await detectProjectName()) ?? "_unknown";

// The session that produced this plan — lets Ask AI offer to fork it
// (#1519). The hook event names it directly (no ancestor-PID resolution
// needed here), but only Claude Code's session id means anything to a
// provider: Gemini isn't a fork-capable harness, and no other origin
// reaches this hook-event branch with a genuine session id.
const planOriginSession: ParentSession | null =
!isGemini && detectedOrigin === "claude-code"
? buildOriginSession({ agent: "claude-code", sessionId: event.session_id, cwd: event.cwd })
: null;

// Start the plan review server
const server = await startPlannotatorServer({
plan: planContent,
origin: isGemini ? "gemini-cli" : detectedOrigin,
originSession: planOriginSession,
permissionMode,
sharingEnabled,
shareBaseUrl,
Expand Down
29 changes: 29 additions & 0 deletions apps/opencode-plugin/cli-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,35 @@ describe("OpenCode CLI bridge helpers", () => {
]);
});

test("appends --session-id when the invoking OpenCode session is known (#1519)", () => {
const parsed: Parameters<typeof buildAnnotateCliArgs>[0] = {
filePath: "plan.html",
rawFilePath: "plan.html",
gate: false,
json: false,
hook: false,
renderHtml: false,
renderMarkdown: false,
noJina: false,
};

expect(buildAnnotateCliArgs(parsed, "session-1")).toEqual([
"annotate",
"plan.html",
"--json",
"--session-id",
"session-1",
]);

// No sessionId (embedded/native paths never hit the CLI-bridge fallback
// leg, so most calls omit it) — the flag must not appear at all.
expect(buildAnnotateCliArgs(parsed)).toEqual([
"annotate",
"plan.html",
"--json",
]);
});

test("requires a session before launching a gated capable annotate bridge", () => {
expect(canLaunchGatedAnnotate({ gate: true }, undefined)).toBe(false);
expect(canLaunchGatedAnnotate({ gate: true }, "session-1")).toBe(true);
Expand Down
Loading