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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

### Fixed

- `/gpt-account add` now shows the OpenAI Codex login-method chooser as a real selector (`Browser login (default)` / `Device code login (headless)`) instead of an empty text input that failed with `Unknown OpenAI Codex login method:` on Enter. The device-code flow prints the user code next to the verification URL, the browser flow opens the browser in the terminal UI and still prints the URL, and the paste-the-code dialog closes by itself once the local callback completes the login. `/claude-account add` shares the same prompt relay ([#1485](https://github.com/code-yeongyu/senpi/issues/1485)).

- Anthropic requests no longer fail with `Tool reference '<name>' not found in available tools` after a native tool search: references that come back under a gateway namespace (`mcp__<id>__<tool>`) are folded onto the request's own tool names before the request is sent, references that no longer resolve are dropped, and a search result left with no references is demoted to text instead of being replayed verbatim. A history tool call whose only justification was such a dangling reference is demoted like any other unavailable call, so one stale native search result can no longer hard-error the model and force a fallback.

- The GPT-6 Astra prompt preset now does the work itself by default: anything that closes in a handful of calls is the model's own, a follow-up on work it delegated earlier is taken back rather than forwarded to the child, and only a sizeable independent track earns a subagent. The routing line opens a new request instead of every turn, so a steering message gets the work rather than a restatement of what was understood, and a new initiative rule consults stored memory for the user's preferences before asking anything memory may already answer. Observed across the 2026-09-06..08 sessions: Astra spent 15-39% of its tool calls on `task` / `task_send` against 2-4% for the Claude and Kimi presets on the same tools.
Expand Down
19 changes: 19 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Builtin extensions changes

## Account commands relay OAuth login prompts through the extension UI (2026-09-08)

### What changed

- `packages/coding-agent/src/core/extensions/builtin/oauth-login-interaction.ts` (new): `createExtensionLoginInteraction(ctx, { providerLabel, openBrowser? })` builds the `AuthInteraction` an account command hands to `modelRuntime.login`. `select` prompts go to `ctx.ui.select` over the option labels and the chosen label is mapped back to the option id; `text`, `secret` and `manual_code` prompts go to `ctx.ui.input` with the provider's placeholder; every dialog carries the command signal combined with the per-prompt `AuthPrompt.signal`, and a dismissed or aborted dialog rejects with `Login cancelled`. `auth_url` events open the browser when `ctx.mode === "tui"` and always print the URL plus the provider's instructions; `device_code` events print the verification URL together with `Enter code: <userCode>`; `info` events print their links.
- `packages/coding-agent/src/core/extensions/builtin/gpt-account.ts`: `addAccount` uses the shared interaction instead of relaying every prompt to `ctx.ui.input(prompt.message)`; the factory accepts an optional `GptAccountExtensionDeps` (`openBrowser`) so tests can observe the browser launch.

### Why

- code-yeongyu/senpi#1485: `/gpt-account add` rendered `Select OpenAI Codex login method:` as an empty text input because the provider's `select` prompt was relayed as text, so the two login methods were never shown and an empty Enter reached the provider as `Unknown OpenAI Codex login method:`. The device-code flow printed the verification URL without the user code, and the browser flow told the user "A browser window should open" without opening one. `/login` already routes these prompts correctly (`core/auth-storage.ts` `handleLegacyPrompt`, `modes/rpc/login-prompts.ts`); the account commands now share one relay with the same rules.

### Why an extension could not handle it

- The commands live in the builtin registry and the relay sits between `modelRuntime.login` and the provider flow, a seam no user extension can interpose on.

### Expected merge conflict zones

- LOW: `gpt-account.ts` is fork-only; `oauth-login-interaction.ts` is new.

## Plugin-root containment resolves against the filesystem (2026-09-07)

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Credential } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionCommandContext } from "../../types.ts";
import { createExtensionLoginInteraction, LOGIN_CANCELLED_MESSAGE } from "../oauth-login-interaction.ts";
import { emitProviderAccountsChanged } from "./account-events.ts";
import { CLAUDE_SDK_OAUTH_PROVIDER_ID, pinProviderAccount, removeProviderAccount } from "./account-management.ts";
import { type AccountSlot, type ClaudeSdkOauthCredential, emptyCredential, listAccounts } from "./accounts.ts";
Expand All @@ -13,8 +14,12 @@ type CommandEnvironment = (name: string) => string | undefined;
export interface ClaudeAccountCommandDeps {
loadSettings?: (cwd: string) => ClaudeSdkOauthProviderSettings;
environment?: CommandEnvironment;
/** Browser launcher for the OAuth authorize URL; tests inject a recorder. */
openBrowser?: ((url: string) => void) | undefined;
}

const CLAUDE_SDK_OAUTH_PROVIDER_LABEL = "Claude SDK OAuth";

function asCredential(value: Credential | undefined): ClaudeSdkOauthCredential | undefined {
return value?.type === "oauth" ? (value as ClaudeSdkOauthCredential) : undefined;
}
Expand Down Expand Up @@ -49,18 +54,6 @@ function parseArgs(rawArgs: string): string[] {
return rawArgs.trim().split(/\s+/).filter(Boolean);
}

function authEventMessage(event: unknown): string {
if (event === null || typeof event !== "object") return "Claude SDK OAuth authentication update.";
const value = event as Record<string, unknown>;
if (value.type === "auth_url" && typeof value.url === "string") {
return `Open this URL to authorize Claude SDK OAuth:\n${value.url}`;
}
if (value.type === "device_code" && typeof value.verificationUri === "string") {
return `Open this URL to authorize Claude SDK OAuth:\n${value.verificationUri}`;
}
return typeof value.message === "string" ? value.message : "Claude SDK OAuth authentication update.";
}

export function getSessionClaudeAccountPin(sessionId: string | undefined): string | undefined {
return sessionId === undefined ? undefined : cliPinsBySession.get(sessionId);
}
Expand Down Expand Up @@ -102,7 +95,7 @@ export function registerClaudeAccountCommand(pi: ExtensionAPI, deps: ClaudeAccou
return;
}
if (action === "add") {
await addAccount(ctx);
await addAccount(ctx, deps);
return;
}
if (action === "remove") {
Expand Down Expand Up @@ -156,26 +149,27 @@ function showAccounts(
ctx.ui.notify(lines.join("\n"), "info");
}

async function addAccount(ctx: ExtensionCommandContext): Promise<void> {
async function addAccount(ctx: ExtensionCommandContext, deps: ClaudeAccountCommandDeps): Promise<void> {
if (!ctx.hasUI) {
ctx.ui.notify("/claude-account add requires an interactive UI.", "error");
return;
}
try {
await ctx.modelRegistry.modelRuntime.login(CLAUDE_SDK_OAUTH_PROVIDER_ID, "oauth", {
signal: ctx.signal,
prompt: async (prompt) => {
const answer = await ctx.ui.input(prompt.message);
if (answer === undefined) throw new Error("Login cancelled");
return answer;
},
notify: (event) => ctx.ui.notify(authEventMessage(event), "info"),
});
await ctx.modelRegistry.modelRuntime.login(
CLAUDE_SDK_OAUTH_PROVIDER_ID,
"oauth",
createExtensionLoginInteraction(ctx, {
providerLabel: CLAUDE_SDK_OAUTH_PROVIDER_LABEL,
openBrowser: deps.openBrowser,
}),
);
emitProviderAccountsChanged(CLAUDE_SDK_OAUTH_PROVIDER_ID);
ctx.ui.notify("Claude SDK OAuth account added.", "info");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message !== "Login cancelled") ctx.ui.notify(`Failed to add Claude SDK OAuth account: ${message}`, "error");
if (message !== LOGIN_CANCELLED_MESSAGE) {
ctx.ui.notify(`Failed to add Claude SDK OAuth account: ${message}`, "error");
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# claude-sdk-oauth

## 2026-09-08 - `/claude-account add` relays login prompts through the shared account-command interaction

### What changed

- `account-command.ts`: `addAccount` builds its `AuthInteraction` with `createExtensionLoginInteraction` from `../oauth-login-interaction.ts` instead of a local relay that sent every prompt to `ctx.ui.input(prompt.message)`. Prompts now honour their type and placeholder and are dismissed when the provider aborts them; `auth_url` opens the browser in the TUI and `device_code` prints the user code. `ClaudeAccountCommandDeps` gains an optional `openBrowser` so tests can observe the launch. The local `authEventMessage` helper is gone.

### Why

- code-yeongyu/senpi#1485 fixed the same relay shape in `/gpt-account add`; the Claude command shared the placeholder, per-prompt-signal and browser gaps, so both commands now use one implementation.

### Why an extension could not handle it

- The command is registered by the builtin provider extension and drives `modelRuntime.login` directly; no user extension can interpose on that relay.

### Expected merge conflict zones

- LOW: `account-command.ts` import block, `ClaudeAccountCommandDeps`, and `addAccount`. Fork-only file.

## 2026-09-07 - Restart bindings survive ledger entries appended after the committed assistant

### What changed
Expand Down
43 changes: 18 additions & 25 deletions packages/coding-agent/src/core/extensions/builtin/gpt-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import {
} from "../../../core/credential-accounts.ts";
import type { ExtensionAPI, ExtensionCommandContext } from "../types.ts";
import { emitProviderAccountsChanged } from "./claude-sdk-oauth/account-events.ts";
import { createExtensionLoginInteraction, LOGIN_CANCELLED_MESSAGE } from "./oauth-login-interaction.ts";

const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const LOGIN_CANCELLED_MESSAGE = "Login cancelled";
const OPENAI_CODEX_PROVIDER_LABEL = "OpenAI Codex OAuth";

export interface GptAccountExtensionDeps {
/** Browser launcher for the browser login method; tests inject a recorder. */
readonly openBrowser?: ((url: string) => void) | undefined;
}

function parseArgs(rawArgs: string): string[] {
return rawArgs.trim().split(/\s+/).filter(Boolean);
Expand All @@ -17,18 +23,6 @@ function usage(ctx: ExtensionCommandContext): void {
ctx.ui.notify("Usage: /gpt-account [add | remove <name> | pin <name> | unpin]", "error");
}

function authEventMessage(event: unknown): string {
if (event === null || typeof event !== "object") return "OpenAI Codex OAuth authentication update.";
const value = event as Record<string, unknown>;
if (value.type === "auth_url" && typeof value.url === "string") {
return `Open this URL to authorize OpenAI Codex OAuth:\n${value.url}`;
}
if (value.type === "device_code" && typeof value.verificationUri === "string") {
return `Open this URL to authorize OpenAI Codex OAuth:\n${value.verificationUri}`;
}
return typeof value.message === "string" ? value.message : "OpenAI Codex OAuth authentication update.";
}

async function showAccounts(ctx: ExtensionCommandContext): Promise<void> {
const accounts = await getCredentialAccounts(ctx.modelRegistry.authStorage, OPENAI_CODEX_PROVIDER_ID);
const lines = ["OpenAI Codex OAuth accounts:"];
Expand All @@ -41,21 +35,20 @@ async function showAccounts(ctx: ExtensionCommandContext): Promise<void> {
ctx.ui.notify(lines.join("\n"), "info");
}

async function addAccount(ctx: ExtensionCommandContext): Promise<void> {
async function addAccount(ctx: ExtensionCommandContext, deps: GptAccountExtensionDeps): Promise<void> {
if (!ctx.hasUI) {
ctx.ui.notify("/gpt-account add requires an interactive UI.", "error");
return;
}
try {
await ctx.modelRegistry.modelRuntime.login(OPENAI_CODEX_PROVIDER_ID, "oauth", {
signal: ctx.signal,
prompt: async (prompt) => {
const answer = await ctx.ui.input(prompt.message);
if (answer === undefined) throw new Error(LOGIN_CANCELLED_MESSAGE);
return answer;
},
notify: (event) => ctx.ui.notify(authEventMessage(event), "info"),
});
await ctx.modelRegistry.modelRuntime.login(
OPENAI_CODEX_PROVIDER_ID,
"oauth",
createExtensionLoginInteraction(ctx, {
providerLabel: OPENAI_CODEX_PROVIDER_LABEL,
openBrowser: deps.openBrowser,
}),
);
emitProviderAccountsChanged(OPENAI_CODEX_PROVIDER_ID);
ctx.ui.notify("OpenAI Codex OAuth account added.", "info");
} catch (error) {
Expand Down Expand Up @@ -83,7 +76,7 @@ async function pinAccount(ctx: ExtensionCommandContext, name: string | undefined
ctx.ui.notify(`Pinned OpenAI Codex OAuth account '${name}'.`, "info");
}

export default function gptAccountExtension(pi: ExtensionAPI): void {
export default function gptAccountExtension(pi: ExtensionAPI, deps: GptAccountExtensionDeps = {}): void {
pi.registerCommand("gpt-account", {
description: "List and manage OpenAI Codex OAuth accounts.",
argumentHint: "[add | remove <name> | pin <name> | unpin]",
Expand All @@ -96,7 +89,7 @@ export default function gptAccountExtension(pi: ExtensionAPI): void {
return;
}
if (action === "add") {
await addAccount(ctx);
await addAccount(ctx, deps);
return;
}
if (action === "remove") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Relay a provider OAuth login (`modelRuntime.login`) onto the extension UI of
* a slash command such as `/gpt-account add`, mirroring what the `/login`
* dialog and `modes/rpc/login-prompts.ts` do for their surfaces.
*
* Two decisions are not visible from the code alone: every dialog is bound to
* the per-prompt `AuthPrompt.signal` as well as the command signal, so a
* manual-code dialog is released when the provider's local callback server
* wins the race (`loginOpenAICodex`); and `auth_url` opens the browser only in
* the TUI, because an RPC client renders the notice on its own machine.
*/

import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai";
import { openBrowser as openPlatformBrowser } from "../../../utils/open-browser.ts";
import type { ExtensionCommandContext } from "../types.ts";

export const LOGIN_CANCELLED_MESSAGE = "Login cancelled";

export interface ExtensionLoginInteractionOptions {
/** Provider name rendered in notices, e.g. "OpenAI Codex OAuth". */
readonly providerLabel: string;
/** Browser launcher for `auth_url` events in the TUI; tests inject a recorder. */
readonly openBrowser?: ((url: string) => void) | undefined;
}

type LoginCommandContext = Pick<ExtensionCommandContext, "mode" | "signal" | "ui">;

export function createExtensionLoginInteraction(
ctx: LoginCommandContext,
options: ExtensionLoginInteractionOptions,
): AuthInteraction {
const openBrowser = options.openBrowser ?? openPlatformBrowser;
return {
signal: ctx.signal,
prompt: (prompt) => relayPrompt(ctx, prompt),
notify: (event) => relayEvent(ctx, event, options.providerLabel, openBrowser),
};
}

function dialogSignal(
commandSignal: AbortSignal | undefined,
promptSignal: AbortSignal | undefined,
): AbortSignal | undefined {
if (commandSignal && promptSignal) return AbortSignal.any([commandSignal, promptSignal]);
return commandSignal ?? promptSignal;
}

async function relayPrompt(ctx: LoginCommandContext, prompt: AuthPrompt): Promise<string> {
const signal = dialogSignal(ctx.signal, prompt.signal);
if (signal?.aborted) throw new Error(LOGIN_CANCELLED_MESSAGE);
const dialogOptions = signal ? { signal } : undefined;
const answer = await answerPrompt(ctx, prompt, dialogOptions);
if (answer === undefined || signal?.aborted) throw new Error(LOGIN_CANCELLED_MESSAGE);
return answer;
}

async function answerPrompt(
ctx: LoginCommandContext,
prompt: AuthPrompt,
dialogOptions: { signal: AbortSignal } | undefined,
): Promise<string | undefined> {
switch (prompt.type) {
case "select": {
const label = await ctx.ui.select(
prompt.message,
prompt.options.map((option) => option.label),
dialogOptions,
);
return prompt.options.find((option) => option.label === label)?.id;
}
case "text":
case "secret":
case "manual_code":
return ctx.ui.input(prompt.message, prompt.placeholder, dialogOptions);
}
}

function relayEvent(
ctx: LoginCommandContext,
event: AuthEvent,
providerLabel: string,
openBrowser: (url: string) => void,
): void {
switch (event.type) {
case "auth_url": {
if (ctx.mode === "tui") openBrowser(event.url);
const lines = [`Open this URL to authorize ${providerLabel}:`, event.url];
if (event.instructions) lines.push(event.instructions);
ctx.ui.notify(lines.join("\n"), "info");
return;
}
case "device_code":
ctx.ui.notify(
[
`Open this URL to authorize ${providerLabel}:`,
event.verificationUri,
`Enter code: ${event.userCode}`,
].join("\n"),
"info",
);
return;
case "info": {
const links = (event.links ?? []).map((link) => (link.label ? `${link.label}: ${link.url}` : link.url));
ctx.ui.notify([event.message, ...links].join("\n"), "info");
return;
}
case "progress":
ctx.ui.notify(event.message, "info");
return;
}
}
Loading