feat(coding-agent): include context in Radius session shares

This commit is contained in:
Armin Ronacher 2026-08-23 20:45:14 +02:00
parent a69bef789b
commit 460191cfcf
6 changed files with 389 additions and 213 deletions

View file

@ -13,7 +13,7 @@
- Changed the bundled Node.js runtime to load jiti only when importing an extension and Babel only when uncached source needs transformation, reducing CLI startup time and bundle size.
- Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time.
- Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity.
- Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL.
- Changed session sharing to render clickable terminal links, display only the canonical Radius artifact URL, and include the current system prompt and active tool definitions in Radius session shares.
### Fixed

View file

@ -13,7 +13,7 @@
* Modes use this class and add their own I/O layer on top.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { basename, dirname } from "node:path";
import type {
Agent,
@ -48,7 +48,6 @@ import {
} from "@earendil-works/pi-ai/compat";
import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts";
import { normalizeToolResultImages } from "../utils/tool-result-images.ts";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
@ -101,8 +100,9 @@ import { ModelRegistry } from "./model-registry.ts";
import type { ModelRuntime } from "./model-runtime.ts";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import { exportSessionToJsonl } from "./session-export.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
import { getLatestCompactionEntry } from "./session-manager.ts";
import type { SettingsManager } from "./settings-manager.ts";
import type { SlashCommandInfo } from "./slash-commands.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
@ -3374,36 +3374,7 @@ export class AgentSession {
* @returns The resolved output file path.
*/
exportToJsonl(outputPath?: string): string {
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: this.sessionManager.getSessionId(),
timestamp: new Date().toISOString(),
cwd: this.sessionManager.getCwd(),
};
const branchEntries = this.sessionManager.getBranch();
const lines = [JSON.stringify(header)];
// Re-chain parentIds to form a linear sequence
let prevId: string | null = null;
for (const entry of branchEntries) {
const linear = { ...entry, parentId: prevId };
lines.push(JSON.stringify(linear));
prevId = entry.id;
}
writeFileSync(filePath, `${lines.join("\n")}\n`);
return filePath;
return exportSessionToJsonl(this.sessionManager, outputPath);
}
// =========================================================================

View file

@ -0,0 +1,42 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionManager } from "./session-manager.ts";
/** Write the current session branch and optional trailing export-only entries as JSONL. */
export function exportSessionToJsonl(
sessionManager: SessionManager,
outputPath?: string,
createTrailingEntries?: (parentId: string | null, timestamp: string) => readonly object[],
): string {
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: sessionManager.getSessionId(),
timestamp,
cwd: sessionManager.getCwd(),
};
const lines = [JSON.stringify(header)];
let parentId: string | null = null;
for (const entry of sessionManager.getBranch()) {
lines.push(JSON.stringify({ ...entry, parentId }));
parentId = entry.id;
}
for (const entry of createTrailingEntries?.(parentId, timestamp) ?? []) {
lines.push(JSON.stringify(entry));
}
writeFileSync(filePath, `${lines.join("\n")}\n`);
return filePath;
}

View file

@ -10,7 +10,6 @@ import * as path from "node:path";
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat";
import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config";
import type {
AutocompleteItem,
AutocompleteProvider,
@ -45,8 +44,7 @@ import {
visibleWidth,
} from "@earendil-works/pi-tui";
import chalk from "chalk";
import { spawn, spawnSync } from "child_process";
import { getAuthCredential } from "../../cli/auth-command.ts";
import { spawn } from "child_process";
import {
APP_NAME,
APP_TITLE,
@ -55,7 +53,6 @@ import {
getAuthPath,
getDebugLogPath,
getDocsPath,
getShareViewerUrl,
VERSION,
} from "../../config.ts";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts";
@ -117,7 +114,6 @@ import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
import { BashExecutionComponent } from "./components/bash-execution.ts";
import { BorderedLoader } from "./components/bordered-loader.ts";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
import { CustomEditor } from "./components/custom-editor.ts";
@ -160,6 +156,7 @@ import { UserMessageSelectorComponent } from "./components/user-message-selector
import { editInExternalEditor } from "./external-editor.ts";
import { refreshModelCatalogs } from "./model-catalog-refresh.ts";
import { getModelSearchText } from "./model-search.ts";
import { shareSession } from "./session-share.ts";
import {
getAvailableThemes,
getAvailableThemesWithPaths,
@ -6092,180 +6089,14 @@ export class InteractiveMode {
}
private async handleShareCommand(): Promise<void> {
// Radius artifacts natively support JSONL sessions. Gist fallback keeps the legacy HTML upload.
const jsonlFile = path.join(os.tmpdir(), "session.jsonl");
let htmlFile = null;
try {
try {
this.session.exportToJsonl(jsonlFile);
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
return;
}
if (await this.tryShareViaRadius(jsonlFile)) return;
try {
const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" });
if (authResult.status !== 0) {
this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
return;
}
} catch {
this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
return;
}
try {
htmlFile = path.join(os.tmpdir(), "session.html");
await this.session.exportToHtml(htmlFile, { themeName: theme.name });
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
return;
}
await this.shareViaGist(htmlFile);
} finally {
for (const tmpFile of [jsonlFile, htmlFile]) {
try {
if (tmpFile !== null) {
fs.unlinkSync(tmpFile);
}
} catch {
// Ignore cleanup errors
}
}
}
}
private async tryShareViaRadius(tmpFile: string): Promise<boolean> {
const provider = this.session.modelRuntime.getProvider("radius");
if (!provider) return false;
const gatewayUrl = DEFAULT_RADIUS_GATEWAY;
const token = getAuthCredential(
await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }),
);
if (!token) return false;
const loader = new BorderedLoader(this.ui, theme, "Uploading to Radius...");
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
loader.onAbort = () => {
this.restoreShareEditor(loader);
this.showStatus("Share cancelled");
};
try {
const body = fs.readFileSync(tmpFile);
const url = new URL("/v1/artifacts", gatewayUrl);
url.searchParams.set("visibility", "organization");
url.searchParams.set("title", "Pi session");
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-ndjson",
"Content-Length": String(body.byteLength),
},
body,
signal: loader.signal,
});
if (loader.signal.aborted) return true;
const json = (await response.json().catch(() => null)) as {
artifact?: { canonical_url: string };
error?: string;
} | null;
if (loader.signal.aborted) return true;
this.restoreShareEditor(loader);
if (!response.ok || !json?.artifact) {
this.showError(
`Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`,
);
return true;
}
const shareUrl = json.artifact.canonical_url;
this.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`);
return true;
} catch (error: unknown) {
if (!loader.signal.aborted) {
this.restoreShareEditor(loader);
this.showError(
`Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
return true;
}
}
private async shareViaGist(tmpFile: string): Promise<void> {
// Show cancellable loader, replacing the editor
const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
// Create a secret gist asynchronously
let proc: ReturnType<typeof spawn> | null = null;
loader.onAbort = () => {
proc?.kill();
this.restoreShareEditor(loader);
this.showStatus("Share cancelled");
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => resolve({ stdout, stderr, code }));
});
if (loader.signal.aborted) return;
this.restoreShareEditor(loader);
if (result.code !== 0) {
const errorMsg = result.stderr?.trim() || "Unknown error";
this.showError(`Failed to create gist: ${errorMsg}`);
return;
}
// Extract gist ID from the URL returned by gh
// gh returns something like: https://gist.github.com/username/GIST_ID
const gistUrl = result.stdout?.trim();
const gistId = gistUrl?.split("/").pop();
if (!gistId) {
this.showError("Failed to parse gist ID from gh output");
return;
}
// Create the preview URL
const previewUrl = getShareViewerUrl(gistId);
this.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`);
} catch (error: unknown) {
if (!loader.signal.aborted) {
this.restoreShareEditor(loader);
this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
}
private restoreShareEditor(loader: BorderedLoader): void {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
await shareSession({
session: this.session,
ui: this.ui,
editorContainer: this.editorContainer,
editor: this.editor,
showStatus: (message) => this.showStatus(message),
showError: (message) => this.showError(message),
});
}
private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise<void> {

View file

@ -0,0 +1,210 @@
import { spawn, spawnSync } from "node:child_process";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config";
import { type Container, type EditorComponent, hyperlink, type TUI } from "@earendil-works/pi-tui";
import { getAuthCredential } from "../../cli/auth-command.ts";
import { getShareViewerUrl } from "../../config.ts";
import type { AgentSession } from "../../core/agent-session.ts";
import { exportSessionToJsonl } from "../../core/session-export.ts";
import { BorderedLoader } from "./components/bordered-loader.ts";
import { theme } from "./theme/theme.ts";
interface SessionShareContext {
session: AgentSession;
ui: TUI;
editorContainer: Container;
editor: EditorComponent;
showStatus: (message: string) => void;
showError: (message: string) => void;
}
/** Export the current branch with presentation metadata for Radius. */
export function exportSessionForShare(filePath: string, session: AgentSession): void {
exportSessionToJsonl(session.sessionManager, filePath, (parentId, timestamp) => [
{
type: "custom",
customType: "pi.share",
id: crypto.randomUUID().slice(0, 8),
parentId,
timestamp,
data: {
systemPrompt: session.state.systemPrompt,
tools: session.state.tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
})),
},
},
]);
}
/** Share the current session through Radius, falling back to a private gist. */
export async function shareSession(context: SessionShareContext): Promise<void> {
const jsonlFile = path.join(os.tmpdir(), "session.jsonl");
let htmlFile: string | null = null;
try {
try {
exportSessionForShare(jsonlFile, context.session);
} catch (error: unknown) {
context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
return;
}
if (await tryShareViaRadius(jsonlFile, context)) return;
try {
const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" });
if (authResult.status !== 0) {
context.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
return;
}
} catch {
context.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
return;
}
try {
htmlFile = path.join(os.tmpdir(), "session.html");
await context.session.exportToHtml(htmlFile, { themeName: theme.name });
} catch (error: unknown) {
context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
return;
}
await shareViaGist(htmlFile, context);
} finally {
for (const tmpFile of [jsonlFile, htmlFile]) {
try {
if (tmpFile !== null) {
fs.unlinkSync(tmpFile);
}
} catch {
// Ignore cleanup errors
}
}
}
}
async function tryShareViaRadius(tmpFile: string, context: SessionShareContext): Promise<boolean> {
const provider = context.session.modelRuntime.getProvider("radius");
if (!provider) return false;
const token = getAuthCredential(
await context.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }),
);
if (!token) return false;
const loader = new BorderedLoader(context.ui, theme, "Uploading to Radius...");
context.editorContainer.clear();
context.editorContainer.addChild(loader);
context.ui.setFocus(loader);
context.ui.requestRender();
loader.onAbort = () => {
restoreEditor(loader, context);
context.showStatus("Share cancelled");
};
try {
const body = fs.readFileSync(tmpFile);
const url = new URL("/v1/artifacts", DEFAULT_RADIUS_GATEWAY);
url.searchParams.set("visibility", "organization");
url.searchParams.set("title", "Pi session");
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-ndjson",
"Content-Length": String(body.byteLength),
},
body,
signal: loader.signal,
});
if (loader.signal.aborted) return true;
const json = (await response.json().catch(() => null)) as {
artifact?: { canonical_url: string };
error?: string;
} | null;
if (loader.signal.aborted) return true;
restoreEditor(loader, context);
if (!response.ok || !json?.artifact) {
context.showError(
`Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`,
);
return true;
}
const shareUrl = json.artifact.canonical_url;
context.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`);
return true;
} catch (error: unknown) {
if (!loader.signal.aborted) {
restoreEditor(loader, context);
context.showError(
`Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
return true;
}
}
async function shareViaGist(tmpFile: string, context: SessionShareContext): Promise<void> {
const loader = new BorderedLoader(context.ui, theme, "Creating gist...");
context.editorContainer.clear();
context.editorContainer.addChild(loader);
context.ui.setFocus(loader);
context.ui.requestRender();
let proc: ReturnType<typeof spawn> | null = null;
loader.onAbort = () => {
proc?.kill();
restoreEditor(loader, context);
context.showStatus("Share cancelled");
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => resolve({ stdout, stderr, code }));
});
if (loader.signal.aborted) return;
restoreEditor(loader, context);
if (result.code !== 0) {
context.showError(`Failed to create gist: ${result.stderr?.trim() || "Unknown error"}`);
return;
}
const gistUrl = result.stdout?.trim();
const gistId = gistUrl?.split("/").pop();
if (!gistId) {
context.showError("Failed to parse gist ID from gh output");
return;
}
const previewUrl = getShareViewerUrl(gistId);
context.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`);
} catch (error: unknown) {
if (!loader.signal.aborted) {
restoreEditor(loader, context);
context.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
}
function restoreEditor(loader: BorderedLoader, context: SessionShareContext): void {
loader.dispose();
context.editorContainer.clear();
context.editorContainer.addChild(context.editor);
context.ui.setFocus(context.editor);
}

View file

@ -0,0 +1,122 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat";
import { getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { defineTool } from "../src/core/extensions/types.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { exportSessionForShare } from "../src/modes/interactive/session-share.ts";
import { assistantMsg, userMsg } from "./utilities.ts";
describe("JSONL share export", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
it("adds presentation data without changing conversation IDs or links", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-jsonl-share-"));
tempDirs.push(tempDir);
const sessionManager = SessionManager.inMemory(tempDir);
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: join(tempDir, "agent"),
model: getModel("anthropic", "claude-sonnet-4-5")!,
settingsManager: SettingsManager.inMemory(),
sessionManager,
tools: ["share_tool"],
customTools: [
defineTool({
name: "share_tool",
label: "Share Tool",
description: "Render a value for sharing",
parameters: Type.Object({ value: Type.String({ description: "Value to render" }) }),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
}),
],
});
try {
const userId = sessionManager.appendMessage(userMsg("hello"));
const assistant: AssistantMessage = {
...assistantMsg(""),
content: [{ type: "toolCall", id: "call-1", name: "share_tool", arguments: { value: "example" } }],
stopReason: "toolUse",
};
const assistantId = sessionManager.appendMessage(assistant);
const result: ToolResultMessage = {
role: "toolResult",
toolCallId: "call-1",
toolName: "share_tool",
content: [{ type: "text", text: "done" }],
details: {},
isError: false,
timestamp: Date.now(),
};
const resultId = sessionManager.appendMessage(result);
const originalEntryIds = sessionManager.getBranch().map((entry) => entry.id);
const normalPath = join(tempDir, "normal.jsonl");
session.exportToJsonl(normalPath);
const normalRecords = readFileSync(normalPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(normalRecords.some((record) => record.type === "custom" && record.customType === "pi.share")).toBe(
false,
);
const sharePath = join(tempDir, "share.jsonl");
exportSessionForShare(sharePath, session);
const records = readFileSync(sharePath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
const conversationRecords = records.slice(1, -1);
expect(conversationRecords.map((record) => record.id)).toEqual(originalEntryIds);
expect(conversationRecords.map((record) => record.parentId)).toEqual([null, ...originalEntryIds.slice(0, -1)]);
expect(conversationRecords.slice(-3).map((record) => record.id)).toEqual([userId, assistantId, resultId]);
const shareEntry = records.at(-1) as {
id: string;
data?: {
systemPrompt?: string;
tools?: Array<Record<string, unknown>>;
};
};
expect(shareEntry).toMatchObject({
type: "custom",
customType: "pi.share",
parentId: resultId,
timestamp: expect.any(String),
});
expect(shareEntry.data?.systemPrompt).toBe(session.state.systemPrompt);
expect(shareEntry.data?.tools).toEqual([
expect.objectContaining({
name: "share_tool",
description: "Render a value for sharing",
}),
]);
expect(shareEntry.data).not.toHaveProperty("renderedTools");
expect(shareEntry.data).not.toHaveProperty("theme");
expect(shareEntry.data).not.toHaveProperty("version");
const imported = SessionManager.open(sharePath);
expect(imported.getLeafId()).toBe(shareEntry.id);
expect(imported.buildSessionContext().messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
]);
} finally {
session.dispose();
}
});
});