From cea257439d5666ad6a987c065eb3ab0e230a135d Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 13 Oct 2026 14:48:41 +0200 Subject: [PATCH] feat(coding-agent): configure session share destination closes #10870 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/sessions.md | 2 +- packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/schemas/settings.schema.json | 18 +++++ .../src/core/settings-defaults.ts | 1 + .../coding-agent/src/core/settings-manager.ts | 15 ++++ .../coding-agent/src/core/settings-schema.ts | 8 +++ .../coding-agent/src/core/slash-commands.ts | 2 +- .../components/settings-selector.ts | 14 ++++ .../src/modes/interactive/interactive-mode.ts | 4 ++ .../src/modes/interactive/session-share.ts | 9 ++- .../coding-agent/test/session-share.test.ts | 70 ++++++++++++++++++- .../test/settings-selector.test.ts | 22 ++++++ 14 files changed, 164 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ec72a6823..357b58478 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -25,6 +25,7 @@ ### Added +- Added the `sessionShareDestination` setting to choose automatic, GitHub gist, or Radius uploads for `/share` ([#10870](https://github.com/earendil-works/pi/issues/10870)) - Added `reachesTools` to tool definitions, so tools that reach other tools (such as `codemode` and `tool_search`) declare which tools a call may reach; the MCP extension uses it to wait for the servers a call needs and to decide whether a replacement `codemode` or `tool_search` tool can reach MCP tools - Added `aliases` to tool namespaces; codemode's `describeNamespace()` and the `namespace` option of `searchTools()` match them, and MCP namespaces use the server name - Added per-key OpenRouter model lists: with an OpenRouter key, Pi lists only the chat models that key may use, with the key's context size, max output, and prices, including guardrails and regional endpoints set as the base URL in `models.json`. The list is saved and used offline ([#10353](https://github.com/earendil-works/pi/issues/10353), [#10672](https://github.com/earendil-works/pi/pull/10672) by [@davidbrai](https://github.com/davidbrai)) diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index 3fc232b28..1a8be36c5 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -55,7 +55,7 @@ Use `--session` when you already know the session path or ID. Use `--fork` to cr ## Export or share a session -Use `/export` to write the current session as HTML or JSONL. Use `/share` to upload it and get a viewer link. Pi uses a Radius artifact when Radius authentication is configured; otherwise, it uses a private GitHub gist. +Use `/export` to write the current session as HTML or JSONL. Use `/share` to upload it and get a viewer link. By default, Pi uses a Radius artifact when Radius authentication is configured; otherwise, it uses a private GitHub gist. Set [`sessionShareDestination`](settings.md#sessions-and-context) to `gist` or `radius` to force one destination. Review exported or shared sessions first. They can contain prompts, model responses, tool arguments, command output, file contents, and extension messages. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 485c27ba3..058736f0c 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -64,6 +64,7 @@ CLI tool options override this setting for one invocation. `--tools` with only ` | Setting | Type | Default | Description | |---|---|---|---| | `sessionDir` | string | Agent session directory | Session storage directory. Relative paths resolve from the working directory. `PI_CODING_AGENT_SESSION_DIR` and `--session-dir` override this setting. | +| `sessionShareDestination` | `"auto" \| "gist" \| "radius"` | `"auto"` | Destination used by `/share`. `auto` uses Radius when authenticated and otherwise creates a GitHub gist. `gist` always uses a gist. `radius` requires Radius authentication and never falls back to a gist. | ### Compaction diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ae227693c..19a6da936 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -79,7 +79,7 @@ Use `!!` when you want to run a command without sending its output to the model. Press `Ctrl+X` or run `/copy` to copy the last assistant response. Use `/export` to save the session as HTML or JSONL. -Use `/share` to upload the session and get a viewer link. With Radius authentication, the artifact is visible to your Radius organization. Otherwise, Pi creates a private GitHub gist through the GitHub CLI. Review the session first because it can contain prompts, tool output, file contents, and credentials exposed during the conversation. +Use `/share` to upload the session and get a viewer link. By default, Radius authentication makes the artifact visible to your Radius organization; otherwise, Pi creates a private GitHub gist through the GitHub CLI. The [`sessionShareDestination`](settings.md#sessions-and-context) setting can force either destination. Review the session first because it can contain prompts, tool output, file contents, and credentials exposed during the conversation. ## Adjust the terminal diff --git a/packages/coding-agent/schemas/settings.schema.json b/packages/coding-agent/schemas/settings.schema.json index 81cfc5fb1..4d6c23a6d 100644 --- a/packages/coding-agent/schemas/settings.schema.json +++ b/packages/coding-agent/schemas/settings.schema.json @@ -50,6 +50,24 @@ ], "default": "auto" }, + "sessionShareDestination": { + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "string", + "const": "gist" + }, + { + "type": "string", + "const": "radius" + } + ], + "description": "Destination used by /share. \"auto\" uses Radius when authenticated and otherwise creates a GitHub gist.", + "default": "auto" + }, "steeringMode": { "anyOf": [ { diff --git a/packages/coding-agent/src/core/settings-defaults.ts b/packages/coding-agent/src/core/settings-defaults.ts index 99fa90497..1f9028045 100644 --- a/packages/coding-agent/src/core/settings-defaults.ts +++ b/packages/coding-agent/src/core/settings-defaults.ts @@ -3,6 +3,7 @@ import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS } from "@earendil-works/pi-ai/utils/re /** Runtime defaults shared by settings accessors and the published JSON Schema. */ export const SETTINGS_DEFAULTS = { transport: "auto", + sessionShareDestination: "auto", steeringMode: "one-at-a-time", followUpMode: "one-at-a-time", compaction: { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 7e2a75030..ad8b82c9e 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -18,6 +18,7 @@ import type { MermaidRenderingMode, PackageSource, QuietStartup, + SessionShareDestination, Settings, ThinkingBudgetsSettings, TransportSetting, @@ -41,6 +42,7 @@ export type { ProviderRetrySettings, QuietStartup, RetrySettings, + SessionShareDestination, Settings, TerminalSettings, ThinkingBudgetsSettings, @@ -802,6 +804,19 @@ export class SettingsManager { this.save(); } + getSessionShareDestination(): SessionShareDestination { + const destination = this.settings.sessionShareDestination; + return destination === "gist" || destination === "radius" + ? destination + : SETTINGS_DEFAULTS.sessionShareDestination; + } + + setSessionShareDestination(destination: SessionShareDestination): void { + this.globalSettings.sessionShareDestination = destination; + this.markModified("sessionShareDestination"); + this.save(); + } + getCompactionEnabled(): boolean { return this.settings.compaction?.enabled ?? SETTINGS_DEFAULTS.compaction.enabled; } diff --git a/packages/coding-agent/src/core/settings-schema.ts b/packages/coding-agent/src/core/settings-schema.ts index 7c800035d..b17bab563 100644 --- a/packages/coding-agent/src/core/settings-schema.ts +++ b/packages/coding-agent/src/core/settings-schema.ts @@ -221,6 +221,13 @@ export const SettingsSchema = Type.Object( { default: SETTINGS_DEFAULTS.transport }, ), ), + sessionShareDestination: Type.Optional( + Type.Union([Type.Literal("auto"), Type.Literal("gist"), Type.Literal("radius")], { + description: + 'Destination used by /share. "auto" uses Radius when authenticated and otherwise creates a GitHub gist.', + default: SETTINGS_DEFAULTS.sessionShareDestination, + }), + ), steeringMode: Type.Optional( Type.Union([Type.Literal("all"), Type.Literal("one-at-a-time")], { default: SETTINGS_DEFAULTS.steeringMode, @@ -464,6 +471,7 @@ export interface CodemodeSettings extends Static export type DefaultProjectTrust = NonNullable; export type QuietStartup = NonNullable; export type TransportSetting = NonNullable; +export type SessionShareDestination = NonNullable; export type PackageSource = Static; export type FullscreenExitOutput = NonNullable; export type TuiMode = NonNullable; diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 934de40a6..dc98f1d43 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -24,7 +24,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, - { name: "share", description: "Share session as a secret GitHub gist" }, + { name: "share", description: "Share session via a Radius artifact or a GitHub gist" }, { name: "bug", description: "Report a bug to the Pi developers", argumentHint: "" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index cdeb80adb..e0cca789f 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -21,6 +21,7 @@ import { type FullscreenExitOutput, type MermaidRenderingMode, type QuietStartup, + type SessionShareDestination, type TuiMode, type WarningSettings, } from "../../../core/settings-manager.ts"; @@ -70,6 +71,7 @@ export interface SettingsConfig { steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; transport: Transport; + sessionShareDestination: SessionShareDestination; httpIdleTimeoutMs: number; cacheWarmingMode: CacheWarmingMode; thinkingLevel: ThinkingLevel; @@ -111,6 +113,7 @@ export interface SettingsCallbacks { onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; + onSessionShareDestinationChange: (destination: SessionShareDestination) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; onCacheWarmingModeChange: (mode: CacheWarmingMode) => void; onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void; @@ -509,6 +512,14 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, + { + id: "share-destination", + label: "Session share destination", + description: + "Where to upload sessions with /share (auto prefers Radius when authenticated, otherwise GitHub Gist)", + currentValue: config.sessionShareDestination, + values: ["auto", "gist", "radius"], + }, { id: "http-idle-timeout", label: "HTTP idle timeout", @@ -900,6 +911,9 @@ export class SettingsSelectorComponent extends Container { case "transport": callbacks.onTransportChange(newValue as Transport); break; + case "share-destination": + callbacks.onSessionShareDestinationChange(newValue as SessionShareDestination); + break; case "http-idle-timeout": { const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue); if (choice) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bf5bb4d67..132c44b18 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4938,6 +4938,7 @@ export class InteractiveMode { steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), + sessionShareDestination: this.settingsManager.getSessionShareDestination(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), cacheWarmingMode: this.settingsManager.getCacheWarmingMode(), thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, @@ -5009,6 +5010,9 @@ export class InteractiveMode { this.settingsManager.setTransport(transport); this.session.agent.transport = transport; }, + onSessionShareDestinationChange: (destination) => { + this.settingsManager.setSessionShareDestination(destination); + }, onHttpIdleTimeoutMsChange: (timeoutMs) => { this.settingsManager.setHttpIdleTimeoutMs(timeoutMs); configureHttpDispatcher(timeoutMs); diff --git a/packages/coding-agent/src/modes/interactive/session-share.ts b/packages/coding-agent/src/modes/interactive/session-share.ts index 784a28141..69081319e 100644 --- a/packages/coding-agent/src/modes/interactive/session-share.ts +++ b/packages/coding-agent/src/modes/interactive/session-share.ts @@ -66,7 +66,14 @@ export async function shareSession(context: SessionShareContext): Promise context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } - if (await tryShareViaRadius(jsonlFile, context)) return; + const destination = context.session.settingsManager.getSessionShareDestination(); + if (destination !== "gist") { + if (await tryShareViaRadius(jsonlFile, context)) return; + if (destination === "radius") { + context.showError("Radius authentication is not configured. Run '/login radius' first."); + return; + } + } try { const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); diff --git a/packages/coding-agent/test/session-share.test.ts b/packages/coding-agent/test/session-share.test.ts index 3c1bf52d9..d46835a0b 100644 --- a/packages/coding-agent/test/session-share.test.ts +++ b/packages/coding-agent/test/session-share.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { readFileSync, writeFileSync } from "node:fs"; import { PassThrough } from "node:stream"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const childProcessMocks = vi.hoisted(() => ({ spawn: vi.fn(), @@ -23,6 +23,7 @@ function deferred(): { promise: Promise; resolve: () => void } { describe("shareSession", () => { beforeAll(() => initTheme("dark")); + beforeEach(() => vi.clearAllMocks()); it("keeps concurrent session exports isolated", async () => { const uploads: string[] = []; @@ -53,6 +54,7 @@ describe("shareSession", () => { getBranch: () => [], }, state: { systemPrompt: name, tools: [] }, + settingsManager: { getSessionShareDestination: () => "auto" }, modelRuntime: { getProvider: () => undefined }, exportToHtml: async (filePath: string) => { writeFileSync(filePath, name); @@ -85,4 +87,70 @@ describe("shareSession", () => { expect(uploads).toEqual(["A", "B"]); expect(errors).toEqual([]); }); + + // #10870: users need persistent gist shares even when Radius authentication is configured. + it("forces gist sharing without checking Radius authentication", async () => { + const getProvider = vi.fn(() => ({ id: "radius" })); + childProcessMocks.spawn.mockImplementation((_command, args: string[]) => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }); + queueMicrotask(() => { + child.stdout.end("https://gist.github.com/test/forced-gist\n"); + child.stderr.end(); + child.emit("close", 0); + }); + expect(readFileSync(args.at(-1)!, "utf8")).toBe("html"); + return child; + }); + const statuses: string[] = []; + + await shareSession({ + session: { + sessionManager: { getSessionId: () => "gist", getCwd: () => "/tmp", getBranch: () => [] }, + state: { systemPrompt: "", tools: [] }, + settingsManager: { getSessionShareDestination: () => "gist" }, + modelRuntime: { getProvider }, + exportToHtml: async (filePath: string) => writeFileSync(filePath, "html"), + }, + ui: { setFocus() {}, requestRender() {} }, + editorContainer: { clear() {}, addChild() {} }, + editor: {}, + showStatus: (message: string) => statuses.push(message), + showError: vi.fn(), + } as never); + + expect(getProvider).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).toHaveBeenCalledWith("gh", [ + "gist", + "create", + "--public=false", + expect.any(String), + ]); + expect(statuses[0]).toContain("https://pi.dev/session/#forced-gist"); + }); + + it("reports missing Radius authentication when Radius is forced", async () => { + const errors: string[] = []; + + await shareSession({ + session: { + sessionManager: { getSessionId: () => "radius", getCwd: () => "/tmp", getBranch: () => [] }, + state: { systemPrompt: "", tools: [] }, + settingsManager: { getSessionShareDestination: () => "radius" }, + modelRuntime: { getProvider: () => undefined }, + }, + ui: { setFocus() {}, requestRender() {} }, + editorContainer: { clear() {}, addChild() {} }, + editor: {}, + showStatus: vi.fn(), + showError: (message: string) => errors.push(message), + } as never); + + expect(errors).toEqual(["Radius authentication is not configured. Run '/login radius' first."]); + expect(childProcessMocks.spawnSync).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 669bb6e5d..35b9bfe23 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -63,6 +63,28 @@ describe("SettingsSelectorComponent", () => { expect(onWheelScrollLinesChange.mock.calls.flat()).toEqual([10, "auto", 1]); }); + it("cycles through share destinations", () => { + const onSessionShareDestinationChange = vi.fn(); + const config = { + sessionShareDestination: "auto", + warnings: {}, + defaultModel: "not set", + availableDefaultModels: [], + availableThinkingLevels: [], + modelThinkingLevels: {}, + availableThemes: [], + } as unknown as SettingsConfig; + const callbacks = { + onSessionShareDestinationChange, + } as unknown as SettingsCallbacks; + const list = new SettingsSelectorComponent(config, callbacks).getSettingsList(); + list.selectItem("share-destination"); + + for (let i = 0; i < 3; i++) list.handleInput("\r"); + + expect(onSessionShareDestinationChange.mock.calls.flat()).toEqual(["gist", "radius", "auto"]); + }); + it("keeps the configured fixed theme marked while browsing", () => { const config = { defaultModel: "not set",