fix: Session Replay and Permission rendering (#593)

Cleans up the replay of loaded sessions.
Also makes always allow permissions clearer of what will be applied.

Closes #579
This commit is contained in:
Ben Brandt
2026-04-24 17:13:02 +02:00
committed by GitHub
parent b3ddfc3b86
commit 5faefab8ab
4 changed files with 484 additions and 3 deletions

View File

@@ -51,6 +51,7 @@ import {
ModelUsage,
Options,
PermissionMode,
PermissionUpdate,
Query,
query,
Settings,
@@ -305,6 +306,56 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
// message and without invoking the model.
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
// their output as user messages in the session transcript, wrapping the
// payload in these XML-like markers that the CLI uses for its own display.
// The live prompt loop drops them; replay must strip them too or they leak
// into the UI on session/load.
const LOCAL_COMMAND_TAG_PATTERN =
/<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
function stripMarkerTags(text: string): string {
return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
}
/**
* Return user-message content with local-command marker tags removed, or
* `null` if nothing meaningful remains (caller should skip the message).
* Preserves real prose that's mixed in alongside the markers — e.g. a
* message like `<command-name>…</command-name>hi` becomes `hi`.
*/
export function stripLocalCommandMetadata(content: unknown): unknown | null {
if (typeof content === "string") {
const stripped = stripMarkerTags(content);
return stripped.trim() === "" ? null : stripped;
}
if (!Array.isArray(content)) return content;
const kept: unknown[] = [];
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as { type: unknown }).type === "text" &&
"text" in block &&
typeof (block as { text: unknown }).text === "string"
) {
const stripped = stripMarkerTags((block as { text: string }).text);
if (stripped.trim() === "") continue;
kept.push({ ...(block as object), text: stripped });
} else {
kept.push(block);
}
}
if (kept.length === 0) return null;
return kept;
}
export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
auto: "auto",
default: "default",
@@ -343,6 +394,50 @@ export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
return mapped;
}
/**
* Builds the label for the "Always Allow" permission option so the user can see
* the exact scope they are committing to. Uses the SDK-provided suggestions
* when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
* tool so "Always Allow" is never a blank check without disclosure.
*/
export function describeAlwaysAllow(
suggestions: PermissionUpdate[] | undefined,
toolName: string,
): string {
if (!suggestions || suggestions.length === 0) {
return `Always Allow all ${toolName}`;
}
const ruleLabels: string[] = [];
const directories: string[] = [];
for (const update of suggestions) {
if (update.type === "addRules" && update.behavior === "allow") {
for (const rule of update.rules) {
ruleLabels.push(
rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`,
);
}
} else if (update.type === "addDirectories") {
directories.push(...update.directories);
}
}
const parts: string[] = [];
if (ruleLabels.length > 0) {
parts.push(ruleLabels.join(", "));
}
if (directories.length > 0) {
parts.push(`access to ${directories.join(", ")}`);
}
if (parts.length === 0) {
return `Always Allow all ${toolName}`;
}
return `Always Allow ${parts.join(" and ")}`;
}
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
@@ -1225,9 +1320,17 @@ export class ClaudeAcpAgent implements Agent {
const messages = await getSessionMessages(sessionId);
for (const message of messages) {
// @ts-expect-error - untyped in SDK but we handle all of these
let content: unknown = message.message.content;
// @ts-expect-error - untyped in SDK but we handle all of these
if (message.message.role === "user") {
content = stripLocalCommandMetadata(content);
if (content === null) continue;
}
for (const notification of toAcpNotifications(
// @ts-expect-error - untyped in SDK but we handle all of these
message.message.content,
content,
// @ts-expect-error - untyped in SDK but we handle all of these
message.message.role,
sessionId,
@@ -1257,6 +1360,7 @@ export class ClaudeAcpAgent implements Agent {
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
const session = this.sessions[sessionId];
if (!session) {
@@ -1347,7 +1451,7 @@ export class ClaudeAcpAgent implements Agent {
options: [
{
kind: "allow_always",
name: "Always Allow",
name: alwaysAllowLabel,
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },