Merge pull request #695 from paperclipai/nm/public-master-polish-batch

Polish inbox, transcripts, and log redaction flows
This commit is contained in:
Dotta
2026-03-12 08:13:33 -05:00
committed by GitHub
29 changed files with 1104 additions and 354 deletions

View File

@@ -33,7 +33,7 @@ Use this skill when leadership asks for:
Before proceeding, verify all of the following: Before proceeding, verify all of the following:
1. `skills/release-changelog/SKILL.md` exists and is usable. 1. `.agents/skills/release-changelog/SKILL.md` exists and is usable.
2. The repo working tree is clean, including untracked files. 2. The repo working tree is clean, including untracked files.
3. There are commits since the last stable tag. 3. There are commits since the last stable tag.
4. The release SHA has passed the verification gate or is about to. 4. The release SHA has passed the verification gate or is about to.

View File

@@ -162,17 +162,73 @@ paperclipai worktree env
eval "$(paperclipai worktree env)" eval "$(paperclipai worktree env)"
``` ```
Useful options: ### Worktree CLI Reference
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
| Option | Description |
|---|---|
| `--name <name>` | Display name used to derive the instance id |
| `--instance <id>` | Explicit isolated instance id |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
| `--from-instance <id>` | Source instance id (default: `default`) |
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Skip database seeding from the source instance |
| `--force` | Replace existing repo-local config and isolated instance data |
Examples:
```sh ```sh
paperclipai worktree init --no-seed paperclipai worktree init --no-seed
paperclipai worktree init --seed-mode minimal
paperclipai worktree init --seed-mode full paperclipai worktree init --seed-mode full
paperclipai worktree init --from-instance default paperclipai worktree init --from-instance default
paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force paperclipai worktree init --force
``` ```
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |
|---|---|
| `--start-point <ref>` | Remote ref to base the new branch on (e.g. `origin/main`) |
| `--instance <id>` | Explicit isolated instance id |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
| `--from-instance <id>` | Source instance id (default: `default`) |
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--no-seed` | Skip database seeding from the source instance |
| `--force` | Replace existing repo-local config and isolated instance data |
Examples:
```sh
pnpm paperclipai worktree:make paperclip-pr-432
pnpm paperclipai worktree:make my-feature --start-point origin/main
pnpm paperclipai worktree:make experiment --no-seed
```
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
| Option | Description |
|---|---|
| `-c, --config <path>` | Path to config file |
| `--json` | Print JSON instead of shell exports |
Examples:
```sh
pnpm paperclipai worktree env
pnpm paperclipai worktree env --json
eval "$(pnpm paperclipai worktree env)"
```
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants. For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## Quick Health Checks ## Quick Health Checks

View File

@@ -58,7 +58,7 @@ From the release worktree:
```bash ```bash
VERSION=X.Y.Z VERSION=X.Y.Z
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
``` ```
### 3. Verify and publish a canary ### 3. Verify and publish a canary
@@ -418,5 +418,5 @@ If the release already exists, the script updates it.
## Related Docs ## Related Docs
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals - [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow - [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow - [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow

View File

@@ -22,3 +22,9 @@ export type {
CLIAdapterModule, CLIAdapterModule,
CreateConfigValues, CreateConfigValues,
} from "./types.js"; } from "./types.js";
export {
REDACTED_HOME_PATH_USER,
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";

View File

@@ -0,0 +1,81 @@
import type { TranscriptEntry } from "./types.js";
export const REDACTED_HOME_PATH_USER = "[]";
const HOME_PATH_PATTERNS = [
{
regex: /\/Users\/[^/\\\s]+/g,
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
},
{
regex: /\/home\/[^/\\\s]+/g,
replace: `/home/${REDACTED_HOME_PATH_USER}`,
},
{
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
replace: `$1${REDACTED_HOME_PATH_USER}`,
},
] as const;
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
export function redactHomePathUserSegments(text: string): string {
let result = text;
for (const pattern of HOME_PATH_PATTERNS) {
result = result.replace(pattern.regex, pattern.replace);
}
return result;
}
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
if (typeof value === "string") {
return redactHomePathUserSegments(value) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
}
if (!isPlainObject(value)) {
return value;
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactHomePathUserSegmentsInValue(entry);
}
return redacted as T;
}
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
switch (entry.kind) {
case "assistant":
case "thinking":
case "user":
case "stderr":
case "system":
case "stdout":
return { ...entry, text: redactHomePathUserSegments(entry.text) };
case "tool_call":
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
case "tool_result":
return { ...entry, content: redactHomePathUserSegments(entry.content) };
case "init":
return {
...entry,
model: redactHomePathUserSegments(entry.model),
sessionId: redactHomePathUserSegments(entry.sessionId),
};
case "result":
return {
...entry,
text: redactHomePathUserSegments(entry.text),
subtype: redactHomePathUserSegments(entry.subtype),
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
};
default:
return entry;
}
}

View File

@@ -1,4 +1,8 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils"; import {
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
type TranscriptEntry,
} from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown { function safeJsonParse(text: string): unknown {
try { try {
@@ -39,12 +43,12 @@ function errorText(value: unknown): string {
} }
function stringifyUnknown(value: unknown): string { function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return redactHomePathUserSegments(value);
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
try { try {
return JSON.stringify(value, null, 2); return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
} catch { } catch {
return String(value); return redactHomePathUserSegments(String(value));
} }
} }
@@ -57,7 +61,8 @@ function parseCommandExecutionItem(
const command = asString(item.command); const command = asString(item.command);
const status = asString(item.status); const status = asString(item.status);
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null; const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
const output = asString(item.aggregated_output).replace(/\s+$/, ""); const safeCommand = redactHomePathUserSegments(command);
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
if (phase === "started") { if (phase === "started") {
return [{ return [{
@@ -67,13 +72,13 @@ function parseCommandExecutionItem(
toolUseId: id || command || "command_execution", toolUseId: id || command || "command_execution",
input: { input: {
id, id,
command, command: safeCommand,
}, },
}]; }];
} }
const lines: string[] = []; const lines: string[] = [];
if (command) lines.push(`command: ${command}`); if (safeCommand) lines.push(`command: ${safeCommand}`);
if (status) lines.push(`status: ${status}`); if (status) lines.push(`status: ${status}`);
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`); if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
if (output) { if (output) {
@@ -104,7 +109,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
.filter((change): change is Record<string, unknown> => Boolean(change)) .filter((change): change is Record<string, unknown> => Boolean(change))
.map((change) => { .map((change) => {
const kind = asString(change.kind, "update"); const kind = asString(change.kind, "update");
const path = asString(change.path, "unknown"); const path = redactHomePathUserSegments(asString(change.path, "unknown"));
return `${kind} ${path}`; return `${kind} ${path}`;
}); });
@@ -126,13 +131,13 @@ function parseCodexItem(
if (itemType === "agent_message") { if (itemType === "agent_message") {
const text = asString(item.text); const text = asString(item.text);
if (text) return [{ kind: "assistant", ts, text }]; if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
return []; return [];
} }
if (itemType === "reasoning") { if (itemType === "reasoning") {
const text = asString(item.text); const text = asString(item.text);
if (text) return [{ kind: "thinking", ts, text }]; if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }]; return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
} }
@@ -148,9 +153,9 @@ function parseCodexItem(
return [{ return [{
kind: "tool_call", kind: "tool_call",
ts, ts,
name: asString(item.name, "unknown"), name: redactHomePathUserSegments(asString(item.name, "unknown")),
toolUseId: asString(item.id), toolUseId: asString(item.id),
input: item.input ?? {}, input: redactHomePathUserSegmentsInValue(item.input ?? {}),
}]; }];
} }
@@ -162,24 +167,28 @@ function parseCodexItem(
asString(item.result) || asString(item.result) ||
stringifyUnknown(item.content ?? item.output ?? item.result); stringifyUnknown(item.content ?? item.output ?? item.result);
const isError = item.is_error === true || asString(item.status) === "error"; const isError = item.is_error === true || asString(item.status) === "error";
return [{ kind: "tool_result", ts, toolUseId, content, isError }]; return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
} }
if (itemType === "error" && phase === "completed") { if (itemType === "error" && phase === "completed") {
const text = errorText(item.message ?? item.error ?? item); const text = errorText(item.message ?? item.error ?? item);
return [{ kind: "stderr", ts, text: text || "error" }]; return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
} }
const id = asString(item.id); const id = asString(item.id);
const status = asString(item.status); const status = asString(item.status);
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" "); const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }]; return [{
kind: "system",
ts,
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
}];
} }
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] { export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line)); const parsed = asRecord(safeJsonParse(line));
if (!parsed) { if (!parsed) {
return [{ kind: "stdout", ts, text: line }]; return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
} }
const type = asString(parsed.type); const type = asString(parsed.type);
@@ -189,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{ return [{
kind: "init", kind: "init",
ts, ts,
model: asString(parsed.model, "codex"), model: redactHomePathUserSegments(asString(parsed.model, "codex")),
sessionId: threadId, sessionId: redactHomePathUserSegments(threadId),
}]; }];
} }
@@ -212,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{ return [{
kind: "result", kind: "result",
ts, ts,
text: asString(parsed.result), text: redactHomePathUserSegments(asString(parsed.result)),
inputTokens, inputTokens,
outputTokens, outputTokens,
cachedTokens, cachedTokens,
costUsd: asNumber(parsed.total_cost_usd), costUsd: asNumber(parsed.total_cost_usd),
subtype: asString(parsed.subtype), subtype: redactHomePathUserSegments(asString(parsed.subtype)),
isError: parsed.is_error === true, isError: parsed.is_error === true,
errors: Array.isArray(parsed.errors) errors: Array.isArray(parsed.errors)
? parsed.errors.map(errorText).filter(Boolean) ? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
: [], : [],
}]; }];
} }
@@ -234,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{ return [{
kind: "result", kind: "result",
ts, ts,
text: asString(parsed.result), text: redactHomePathUserSegments(asString(parsed.result)),
inputTokens, inputTokens,
outputTokens, outputTokens,
cachedTokens, cachedTokens,
costUsd: asNumber(parsed.total_cost_usd), costUsd: asNumber(parsed.total_cost_usd),
subtype: asString(parsed.subtype, "turn.failed"), subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
isError: true, isError: true,
errors: message ? [message] : [], errors: message ? [redactHomePathUserSegments(message)] : [],
}]; }];
} }
if (type === "error") { if (type === "error") {
const message = errorText(parsed.message ?? parsed.error ?? parsed); const message = errorText(parsed.message ?? parsed.error ?? parsed);
return [{ kind: "stderr", ts, text: message || line }]; return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
} }
return [{ kind: "stdout", ts, text: line }]; return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
} }

View File

@@ -7,61 +7,109 @@
* working tree (not just staged changes). * working tree (not just staged changes).
* *
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok). * Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
* If the file is missing, the check passes silently — other developers * If the file is missing, the check still uses the active local username when
* on the project won't have this list, and that's fine. * available. If username detection fails, the check degrades gracefully.
*/ */
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); function uniqueNonEmpty(values) {
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim(); return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
const tokensFile = resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt");
if (!existsSync(tokensFile)) {
console.log(" Forbidden tokens list not found — skipping check.");
process.exit(0);
} }
const tokens = readFileSync(tokensFile, "utf8") export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) {
.split("\n") const candidates = [env.USER, env.LOGNAME, env.USERNAME];
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
if (tokens.length === 0) {
console.log(" Forbidden tokens list is empty — skipping check.");
process.exit(0);
}
// Use git grep to search tracked files only (avoids node_modules, dist, etc.)
let found = false;
for (const token of tokens) {
try { try {
const result = execSync( candidates.push(osModule.userInfo().username);
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
);
if (result.trim()) {
if (!found) {
console.error("ERROR: Forbidden tokens found in tracked files:\n");
}
found = true;
// Print matches but DO NOT print which token was matched (avoids leaking the list)
const lines = result.trim().split("\n");
for (const line of lines) {
console.error(` ${line}`);
}
}
} catch { } catch {
// git grep returns exit code 1 when no matches — that's fine // Some environments do not expose userInfo; env vars are enough fallback.
} }
return uniqueNonEmpty(candidates);
} }
if (found) { export function readForbiddenTokensFile(tokensFile) {
console.error("\nBuild blocked. Remove the forbidden token(s) before publishing."); if (!existsSync(tokensFile)) return [];
process.exit(1);
} else { return readFileSync(tokensFile, "utf8")
console.log(" ✓ No forbidden tokens found."); .split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
}
export function resolveForbiddenTokens(tokensFile, env = process.env, osModule = os) {
return uniqueNonEmpty([
...resolveDynamicForbiddenTokens(env, osModule),
...readForbiddenTokensFile(tokensFile),
]);
}
export function runForbiddenTokenCheck({
repoRoot,
tokens,
exec = execSync,
log = console.log,
error = console.error,
}) {
if (tokens.length === 0) {
log(" Forbidden tokens list is empty — skipping check.");
return 0;
}
let found = false;
for (const token of tokens) {
try {
const result = exec(
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
);
if (result.trim()) {
if (!found) {
error("ERROR: Forbidden tokens found in tracked files:\n");
}
found = true;
const lines = result.trim().split("\n");
for (const line of lines) {
error(` ${line}`);
}
}
} catch {
// git grep returns exit code 1 when no matches — that's fine
}
}
if (found) {
error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
return 1;
}
log(" ✓ No forbidden tokens found.");
return 0;
}
function resolveRepoPaths(exec = execSync) {
const repoRoot = exec("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
const gitDir = exec("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
return {
repoRoot,
tokensFile: resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"),
};
}
function main() {
const { repoRoot, tokensFile } = resolveRepoPaths();
const tokens = resolveForbiddenTokens(tokensFile);
process.exit(runForbiddenTokenCheck({ repoRoot, tokens }));
}
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
main();
} }

View File

@@ -107,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
item: { item: {
id: "item_52", id: "item_52",
type: "file_change", type: "file_change",
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }], changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
status: "completed", status: "completed",
}, },
}), }),
@@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{ {
kind: "system", kind: "system",
ts, ts,
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx", text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
}, },
]); ]);
}); });

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
const {
resolveDynamicForbiddenTokens,
resolveForbiddenTokens,
runForbiddenTokenCheck,
} = await import("../../../scripts/check-forbidden-tokens.mjs");
describe("forbidden token check", () => {
it("derives username tokens without relying on whoami", () => {
const tokens = resolveDynamicForbiddenTokens(
{ USER: "paperclip", LOGNAME: "paperclip", USERNAME: "pc" },
{
userInfo: () => ({ username: "paperclip" }),
},
);
expect(tokens).toEqual(["paperclip", "pc"]);
});
it("falls back cleanly when user resolution fails", () => {
const tokens = resolveDynamicForbiddenTokens(
{},
{
userInfo: () => {
throw new Error("missing user");
},
},
);
expect(tokens).toEqual([]);
});
it("merges dynamic and file-based forbidden tokens", async () => {
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tokensFile = path.join(os.tmpdir(), `forbidden-tokens-${Date.now()}.txt`);
fs.writeFileSync(tokensFile, "# comment\npaperclip\ncustom-token\n");
try {
const tokens = resolveForbiddenTokens(tokensFile, { USER: "paperclip" }, {
userInfo: () => ({ username: "paperclip" }),
});
expect(tokens).toEqual(["paperclip", "custom-token"]);
} finally {
fs.unlinkSync(tokensFile);
}
});
it("reports matches without leaking which token was searched", () => {
const exec = vi
.fn()
.mockReturnValueOnce("server/file.ts:1:found\n")
.mockImplementation(() => {
throw new Error("not found");
});
const log = vi.fn();
const error = vi.fn();
const exitCode = runForbiddenTokenCheck({
repoRoot: "/repo",
tokens: ["paperclip", "custom-token"],
exec,
log,
error,
});
expect(exitCode).toBe(1);
expect(exec).toHaveBeenCalledTimes(2);
expect(error).toHaveBeenCalledWith("ERROR: Forbidden tokens found in tracked files:\n");
expect(error).toHaveBeenCalledWith(" server/file.ts:1:found");
expect(error).toHaveBeenCalledWith("\nBuild blocked. Remove the forbidden token(s) before publishing.");
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
const input = [
`cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`,
`win=C:\\Users\\${userName}\\paperclip`,
].join("\n");
const result = redactCurrentUserText(input, {
userNames: [userName],
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
});
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
userNames: [userName],
homeDirs: [],
},
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`,
nested: {
author: userName,
},
values: [userName, `/home/${userName}/project`],
}, {
userNames: [userName],
homeDirs: [`/Users/${userName}`, `/home/${userName}`],
});
expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
});
});
});

138
server/src/log-redaction.ts Normal file
View File

@@ -0,0 +1,138 @@
import os from "node:os";
export const CURRENT_USER_REDACTION_TOKEN = "[]";
interface CurrentUserRedactionOptions {
replacement?: string;
userNames?: string[];
homeDirs?: string[];
}
type CurrentUserCandidates = {
userNames: string[];
homeDirs: string[];
replacement: string;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function uniqueNonEmpty(values: Array<string | null | undefined>) {
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
}
function splitPathSegments(value: string) {
return value.replace(/[\\/]+$/, "").split(/[\\/]+/).filter(Boolean);
}
function replaceLastPathSegment(pathValue: string, replacement: string) {
const normalized = pathValue.replace(/[\\/]+$/, "");
const lastSeparator = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
if (lastSeparator < 0) return replacement;
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
}
function defaultUserNames() {
const candidates = [
process.env.USER,
process.env.LOGNAME,
process.env.USERNAME,
];
try {
candidates.push(os.userInfo().username);
} catch {
// Some environments do not expose userInfo; env vars are enough fallback.
}
return uniqueNonEmpty(candidates);
}
function defaultHomeDirs(userNames: string[]) {
const candidates: Array<string | null | undefined> = [
process.env.HOME,
process.env.USERPROFILE,
];
try {
candidates.push(os.homedir());
} catch {
// Ignore and fall back to env hints below.
}
for (const userName of userNames) {
candidates.push(`/Users/${userName}`);
candidates.push(`/home/${userName}`);
candidates.push(`C:\\Users\\${userName}`);
}
return uniqueNonEmpty(candidates);
}
let cachedCurrentUserCandidates: CurrentUserCandidates | null = null;
function getDefaultCurrentUserCandidates(): CurrentUserCandidates {
if (cachedCurrentUserCandidates) return cachedCurrentUserCandidates;
const userNames = defaultUserNames();
cachedCurrentUserCandidates = {
userNames,
homeDirs: defaultHomeDirs(userNames),
replacement: CURRENT_USER_REDACTION_TOKEN,
};
return cachedCurrentUserCandidates;
}
function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
const defaults = getDefaultCurrentUserCandidates();
const userNames = uniqueNonEmpty(opts?.userNames ?? defaults.userNames);
const homeDirs = uniqueNonEmpty(opts?.homeDirs ?? defaults.homeDirs);
const replacement = opts?.replacement?.trim() || defaults.replacement;
return { userNames, homeDirs, replacement };
}
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
if (!input) return input;
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = userNames.includes(lastSegment)
? replaceLastPathSegment(homeDir, replacement)
: replacement;
result = result.split(homeDir).join(replacementDir);
}
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement);
}
return result;
}
export function redactCurrentUserValue<T>(value: T, opts?: CurrentUserRedactionOptions): T {
if (typeof value === "string") {
return redactCurrentUserText(value, opts) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => redactCurrentUserValue(entry, opts)) as T;
}
if (!isPlainObject(value)) {
return value;
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactCurrentUserValue(entry, opts);
}
return redacted as T;
}

View File

@@ -31,6 +31,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js"; import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import { import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@@ -1360,7 +1361,7 @@ export function agentRoutes(db: Db) {
return; return;
} }
assertCompanyAccess(req, run.companyId); assertCompanyAccess(req, run.companyId);
res.json(run); res.json(redactCurrentUserValue(run));
}); });
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
@@ -1395,10 +1396,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0); const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200); const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200); const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const redactedEvents = events.map((event) => ({ const redactedEvents = events.map((event) =>
...event, redactCurrentUserValue({
payload: redactEventPayload(event.payload), ...event,
})); payload: redactEventPayload(event.payload),
}),
);
res.json(redactedEvents); res.json(redactedEvents);
}); });
@@ -1495,7 +1498,7 @@ export function agentRoutes(db: Db) {
} }
res.json({ res.json({
...run, ...redactCurrentUserValue(run),
agentId: agent.id, agentId: agent.id,
agentName: agent.name, agentName: agent.name,
adapterType: agent.adapterType, adapterType: agent.adapterType,

View File

@@ -1,6 +1,7 @@
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { activityLog } from "@paperclipai/db"; import { activityLog } from "@paperclipai/db";
import { publishLiveEvent } from "./live-events.js"; import { publishLiveEvent } from "./live-events.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js"; import { sanitizeRecord } from "../redaction.js";
export interface LogActivityInput { export interface LogActivityInput {
@@ -17,6 +18,7 @@ export interface LogActivityInput {
export async function logActivity(db: Db, input: LogActivityInput) { export async function logActivity(db: Db, input: LogActivityInput) {
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null; const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
await db.insert(activityLog).values({ await db.insert(activityLog).values({
companyId: input.companyId, companyId: input.companyId,
actorType: input.actorType, actorType: input.actorType,
@@ -26,7 +28,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
entityId: input.entityId, entityId: input.entityId,
agentId: input.agentId ?? null, agentId: input.agentId ?? null,
runId: input.runId ?? null, runId: input.runId ?? null,
details: sanitizedDetails, details: redactedDetails,
}); });
publishLiveEvent({ publishLiveEvent({
@@ -40,7 +42,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
entityId: input.entityId, entityId: input.entityId,
agentId: input.agentId ?? null, agentId: input.agentId ?? null,
runId: input.runId ?? null, runId: input.runId ?? null,
details: sanitizedDetails, details: redactedDetails,
}, },
}); });
} }

View File

@@ -2,9 +2,17 @@ import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
import { notifyHireApproved } from "./hire-hook.js"; import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
export function approvalService(db: Db) { export function approvalService(db: Db) {
const agentsSvc = agentService(db); const agentsSvc = agentService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]); const canResolveStatuses = new Set(["pending", "revision_requested"]);
@@ -215,7 +223,8 @@ export function approvalService(db: Db) {
eq(approvalComments.companyId, existing.companyId), eq(approvalComments.companyId, existing.companyId),
), ),
) )
.orderBy(asc(approvalComments.createdAt)); .orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment));
}, },
addComment: async ( addComment: async (
@@ -224,6 +233,7 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string }, actor: { agentId?: string; userId?: string },
) => { ) => {
const existing = await getExistingApproval(approvalId); const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body);
return db return db
.insert(approvalComments) .insert(approvalComments)
.values({ .values({
@@ -231,10 +241,10 @@ export function approvalService(db: Db) {
approvalId, approvalId,
authorAgentId: actor.agentId ?? null, authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null, authorUserId: actor.userId ?? null,
body, body: redactedBody,
}) })
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => redactApprovalComment(rows[0]));
}, },
}; };
} }

View File

@@ -39,6 +39,7 @@ import {
parseProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode, resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js"; } from "./execution-workspace-policy.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -811,6 +812,9 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}, },
) { ) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
await db.insert(heartbeatRunEvents).values({ await db.insert(heartbeatRunEvents).values({
companyId: run.companyId, companyId: run.companyId,
runId: run.id, runId: run.id,
@@ -820,8 +824,8 @@ export function heartbeatService(db: Db) {
stream: event.stream, stream: event.stream,
level: event.level, level: event.level,
color: event.color, color: event.color,
message: event.message, message: sanitizedMessage,
payload: event.payload, payload: sanitizedPayload,
}); });
publishLiveEvent({ publishLiveEvent({
@@ -835,8 +839,8 @@ export function heartbeatService(db: Db) {
stream: event.stream ?? null, stream: event.stream ?? null,
level: event.level ?? null, level: event.level ?? null,
color: event.color ?? null, color: event.color ?? null,
message: event.message ?? null, message: sanitizedMessage ?? null,
payload: event.payload ?? null, payload: sanitizedPayload ?? null,
}, },
}); });
} }
@@ -1325,22 +1329,23 @@ export function heartbeatService(db: Db) {
.where(eq(heartbeatRuns.id, runId)); .where(eq(heartbeatRuns.id, runId));
const onLog = async (stream: "stdout" | "stderr", chunk: string) => { const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk); const sanitizedChunk = redactCurrentUserText(chunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString(); const ts = new Date().toISOString();
if (handle) { if (handle) {
await runLogStore.append(handle, { await runLogStore.append(handle, {
stream, stream,
chunk, chunk: sanitizedChunk,
ts, ts,
}); });
} }
const payloadChunk = const payloadChunk =
chunk.length > MAX_LIVE_LOG_CHUNK_BYTES sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
? chunk.slice(chunk.length - MAX_LIVE_LOG_CHUNK_BYTES) ? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
: chunk; : sanitizedChunk;
publishLiveEvent({ publishLiveEvent({
companyId: run.companyId, companyId: run.companyId,
@@ -1351,7 +1356,7 @@ export function heartbeatService(db: Db) {
ts, ts,
stream, stream,
chunk: payloadChunk, chunk: payloadChunk,
truncated: payloadChunk.length !== chunk.length, truncated: payloadChunk.length !== sanitizedChunk.length,
}, },
}); });
}; };
@@ -1542,7 +1547,9 @@ export function heartbeatService(db: Db) {
error: error:
outcome === "succeeded" outcome === "succeeded"
? null ? null
: adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), : redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
),
errorCode: errorCode:
outcome === "timed_out" outcome === "timed_out"
? "timeout" ? "timeout"
@@ -1609,7 +1616,7 @@ export function heartbeatService(db: Db) {
} }
await finalizeAgentStatus(agent.id, outcome); await finalizeAgentStatus(agent.id, outcome);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown adapter failure"; const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
logger.error({ err, runId }, "heartbeat execution failed"); logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -2395,6 +2402,7 @@ export function heartbeatService(db: Db) {
store: run.logStore, store: run.logStore,
logRef: run.logRef, logRef: run.logRef,
...result, ...result,
content: redactCurrentUserText(result.content),
}; };
}, },

View File

@@ -22,6 +22,7 @@ import {
defaultIssueExecutionWorkspaceSettingsForProject, defaultIssueExecutionWorkspaceSettingsForProject,
parseProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js"; } from "./execution-workspace-policy.js";
import { redactCurrentUserText } from "../log-redaction.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -88,6 +89,13 @@ type IssueUserContextInput = {
updatedAt: Date | string; updatedAt: Date | string;
}; };
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId; if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null; return checkoutRunId == null;
@@ -1041,14 +1049,18 @@ export function issueService(db: Db) {
.select() .select()
.from(issueComments) .from(issueComments)
.where(eq(issueComments.issueId, issueId)) .where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt)), .orderBy(desc(issueComments.createdAt))
.then((comments) => comments.map(redactIssueComment)),
getComment: (commentId: string) => getComment: (commentId: string) =>
db db
.select() .select()
.from(issueComments) .from(issueComments)
.where(eq(issueComments.id, commentId)) .where(eq(issueComments.id, commentId))
.then((rows) => rows[0] ?? null), .then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => { addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db const issue = await db
@@ -1059,6 +1071,7 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found"); if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const [comment] = await db const [comment] = await db
.insert(issueComments) .insert(issueComments)
.values({ .values({
@@ -1066,7 +1079,7 @@ export function issueService(db: Db) {
issueId, issueId,
authorAgentId: actor.agentId ?? null, authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null, authorUserId: actor.userId ?? null,
body, body: redactedBody,
}) })
.returning(); .returning();
@@ -1076,7 +1089,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() }) .set({ updatedAt: new Date() })
.where(eq(issues.id, issueId)); .where(eq(issues.id, issueId));
return comment; return redactIssueComment(comment);
}, },
createAttachment: async (input: { createAttachment: async (input: {

View File

@@ -1,3 +1,4 @@
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
import type { TranscriptEntry, StdoutLineParser } from "./types"; import type { TranscriptEntry, StdoutLineParser } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
@@ -26,11 +27,11 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const chunk of chunks) { for (const chunk of chunks) {
if (chunk.stream === "stderr") { if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk }); entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
continue; continue;
} }
if (chunk.stream === "system") { if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk }); entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
continue; continue;
} }
@@ -40,14 +41,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts)); appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths));
} }
} }
const trailing = stdoutBuffer.trim(); const trailing = stdoutBuffer.trim();
if (trailing) { if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts)); appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths));
} }
return entries; return entries;

View File

@@ -108,7 +108,7 @@ function AgentRunCard({
) : ( ) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" /> <span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)} )}
<Identity name={run.agentName} size="sm" /> <Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-[11px]" />
</div> </div>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground"> <div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span> <span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
@@ -147,6 +147,7 @@ function AgentRunCard({
limit={5} limit={5}
streaming={isActive} streaming={isActive}
collapseStdout collapseStdout
thinkingClassName="!text-[10px] !leading-4"
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."} emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
/> />
</div> </div>

View File

@@ -0,0 +1,127 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { PriorityIcon } from "./PriorityIcon";
import { StatusIcon } from "./StatusIcon";
type UnreadState = "hidden" | "visible" | "fading";
interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
desktopTrailing?: ReactNode;
trailingMeta?: ReactNode;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
className?: string;
}
export function IssueRow({
issue,
issueLinkState,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
mobileMeta,
desktopTrailing,
trailingMeta,
unreadState = null,
onMarkRead,
className,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<Link
to={`/issues/${issuePathId}`}
state={issueLinkState}
className={cn(
"flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
className,
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" />
) : null}
{desktopMetaLeading ?? (
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
</>
)}
{mobileMeta ? (
<>
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
</>
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{showUnreadSlot ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
) : null}
</Link>
);
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -12,6 +11,7 @@ import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { IssueRow } from "./IssueRow";
import { PageSkeleton } from "./PageSkeleton"; import { PageSkeleton } from "./PageSkeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -590,162 +590,166 @@ export function IssuesList({
)} )}
<CollapsibleContent> <CollapsibleContent>
{group.items.map((issue) => ( {group.items.map((issue) => (
<Link <IssueRow
key={issue.id} key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`} issue={issue}
state={issueLinkState} issueLinkState={issueLinkState}
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" desktopLeadingSpacer
> mobileLeading={(
{/* Status icon - left column on mobile, inline on desktop */} <span
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}> onClick={(e) => {
<StatusIcon e.preventDefault();
status={issue.status} e.stopPropagation();
onChange={(s) => onUpdateIssue(issue.id, { status: s })} }}
/> >
</span> <StatusIcon
status={issue.status}
{/* Right column on mobile: title + metadata stacked */} onChange={(s) => onUpdateIssue(issue.id, { status: s })}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents"> />
{/* Title line */}
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span> </span>
)}
{/* Metadata line */} desktopMetaLeading={(
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0"> <>
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} <span className="hidden sm:inline-flex">
<span className="w-3.5 shrink-0 hidden sm:block" /> <PriorityIcon priority={issue.priority} />
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span> </span>
<span className="hidden shrink-0 sm:inline-flex" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}> <span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon <StatusIcon
status={issue.status} status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })} onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/> />
</span> </span>
<span className="text-xs text-muted-foreground font-mono shrink-0"> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)} {issue.identifier ?? issue.id.slice(0, 8)}
</span> </span>
{liveIssueIds?.has(issue.id) && ( {liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10"> <span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" /> <span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span> </span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
</span> </span>
)} )}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span> </>
<span className="text-xs text-muted-foreground sm:hidden"> )}
{timeAgo(issue.updatedAt)} mobileMeta={timeAgo(issue.updatedAt)}
</span> desktopTrailing={(
</span> <>
</span> {(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{/* Desktop-only trailing content */} {(issue.labels ?? []).slice(0, 3).map((label) => (
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto"> <span
{(issue.labels ?? []).length > 0 && ( key={label.id}
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden"> className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
{(issue.labels ?? []).slice(0, 3).map((label) => ( style={{
<span borderColor: label.color,
key={label.id} color: label.color,
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium" backgroundColor: `${label.color}1f`,
style={{ }}
borderColor: label.color, >
color: label.color, {label.name}
backgroundColor: `${label.color}1f`, </span>
}} ))}
> {(issue.labels ?? []).length > 3 && (
{label.name} <span className="text-[10px] text-muted-foreground">
</span> +{(issue.labels ?? []).length - 3}
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span> </span>
)} )}
</button> </span>
</PopoverTrigger> )}
<PopoverContent <Popover
className="w-56 p-1" open={assigneePickerIssueId === issue.id}
align="end" onOpenChange={(open) => {
onClick={(e) => e.stopPropagation()} setAssigneePickerIssueId(open ? issue.id : null);
onPointerDownOutside={() => setAssigneeSearch("")} if (!open) setAssigneeSearch("");
}}
> >
<input <PopoverTrigger asChild>
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button <button
className={cn( className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent"
)}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
assignIssue(issue.id, null);
}} }}
> >
No assignee {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button> </button>
{(agents ?? []) </PopoverTrigger>
.filter((agent) => { <PopoverContent
if (!assigneeSearch.trim()) return true; className="w-56 p-1"
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); align="end"
}) onClick={(e) => e.stopPropagation()}
.map((agent) => ( onPointerDownOutside={() => setAssigneeSearch("")}
<button >
key={agent.id} <input
className={cn( className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left", placeholder="Search agents..."
issue.assigneeAgentId === agent.id && "bg-accent" value={assigneeSearch}
)} onChange={(e) => setAssigneeSearch(e.target.value)}
onClick={(e) => { autoFocus
e.preventDefault(); />
e.stopPropagation(); <div className="max-h-48 overflow-y-auto overscroll-contain">
assignIssue(issue.id, agent.id); <button
}} className={cn(
> "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
<Identity name={agent.name} size="sm" className="min-w-0" /> !issue.assigneeAgentId && "bg-accent",
</button> )}
))} onClick={(e) => {
</div> e.preventDefault();
</PopoverContent> e.stopPropagation();
</Popover> assignIssue(issue.id, null);
<span className="text-xs text-muted-foreground"> }}
{formatDate(issue.createdAt)} >
</span> No assignee
</span> </button>
</Link> {(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))} ))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View File

@@ -1034,8 +1034,6 @@ export function NewIssueDialog() {
</span> </span>
) : createIssue.isError ? ( ) : createIssue.isError ? (
<span className="text-xs text-destructive">{createIssueErrorMessage}</span> <span className="text-xs text-destructive">{createIssueErrorMessage}</span>
) : canDiscardDraft ? (
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
) : null} ) : null}
</div> </div>
<Button <Button

View File

@@ -0,0 +1,84 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { TranscriptEntry } from "../../adapters";
import { ThemeProvider } from "../../context/ThemeContext";
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
describe("RunTranscriptView", () => {
it("keeps running command stdout inside the command fold instead of a standalone stdout block", () => {
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts: "2026-03-12T00:00:00.000Z",
name: "command_execution",
toolUseId: "cmd_1",
input: { command: "ls -la" },
},
{
kind: "stdout",
ts: "2026-03-12T00:00:01.000Z",
text: "file-a\nfile-b",
},
];
const blocks = normalizeTranscript(entries, false);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
type: "command_group",
items: [{ result: "file-a\nfile-b", status: "running" }],
});
});
it("renders assistant and thinking content as markdown in compact mode", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<RunTranscriptView
density="compact"
entries={[
{
kind: "assistant",
ts: "2026-03-12T00:00:00.000Z",
text: "Hello **world**",
},
{
kind: "thinking",
ts: "2026-03-12T00:00:01.000Z",
text: "- first\n- second",
},
]}
/>
</ThemeProvider>,
);
expect(html).toContain("<strong>world</strong>");
expect(html).toContain("<li>first</li>");
expect(html).toContain("<li>second</li>");
});
it("hides saved-session resume skip stderr from nice mode normalization", () => {
const entries: TranscriptEntry[] = [
{
kind: "stderr",
ts: "2026-03-12T00:00:00.000Z",
text: "[paperclip] Skipping saved session resume for task \"PAP-485\" because wake reason is issue_assigned.",
},
{
kind: "assistant",
ts: "2026-03-12T00:00:01.000Z",
text: "Working on the task.",
},
];
const blocks = normalizeTranscript(entries, false);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
type: "message",
role: "assistant",
text: "Working on the task.",
});
});
});

View File

@@ -24,6 +24,7 @@ interface RunTranscriptViewProps {
collapseStdout?: boolean; collapseStdout?: boolean;
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
thinkingClassName?: string;
} }
type TranscriptBlock = type TranscriptBlock =
@@ -98,16 +99,6 @@ function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}` : value; return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}` : value;
} }
function stripMarkdown(value: string): string {
return compactWhitespace(
value
.replace(/```[\s\S]*?```/g, " code ")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_#>-]/g, " "),
);
}
function humanizeLabel(value: string): string { function humanizeLabel(value: string): string {
return value return value
.replace(/[_-]+/g, " ") .replace(/[_-]+/g, " ")
@@ -285,6 +276,11 @@ function parseSystemActivity(text: string): { activityId?: string; name: string;
}; };
} }
function shouldHideNiceModeStderr(text: string): boolean {
const normalized = compactWhitespace(text).toLowerCase();
return normalized.startsWith("[paperclip] skipping saved session resume");
}
function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
const grouped: TranscriptBlock[] = []; const grouped: TranscriptBlock[] = [];
let pending: Array<Extract<TranscriptBlock, { type: "command_group" }>["items"][number]> = []; let pending: Array<Extract<TranscriptBlock, { type: "command_group" }>["items"][number]> = [];
@@ -329,7 +325,7 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
return grouped; return grouped;
} }
function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
const blocks: TranscriptBlock[] = []; const blocks: TranscriptBlock[] = [];
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>(); const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>(); const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>();
@@ -438,6 +434,9 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
} }
if (entry.kind === "stderr") { if (entry.kind === "stderr") {
if (shouldHideNiceModeStderr(entry.text)) {
continue;
}
blocks.push({ blocks.push({
type: "event", type: "event",
ts: entry.ts, ts: entry.ts,
@@ -486,6 +485,17 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
continue; continue;
} }
const activeCommandBlock = [...blocks].reverse().find(
(block): block is Extract<TranscriptBlock, { type: "tool" }> =>
block.type === "tool" && block.status === "running" && isCommandTool(block.name, block.input),
);
if (activeCommandBlock) {
activeCommandBlock.result = activeCommandBlock.result
? `${activeCommandBlock.result}${activeCommandBlock.result.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`}`
: entry.text;
continue;
}
if (previous?.type === "stdout") { if (previous?.type === "stdout") {
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
previous.ts = entry.ts; previous.ts = entry.ts;
@@ -519,15 +529,14 @@ function TranscriptMessageBlock({
<span>User</span> <span>User</span>
</div> </div>
)} )}
{compact ? ( <MarkdownBody
<div className="text-xs leading-5 text-foreground/85 whitespace-pre-wrap break-words"> className={cn(
{truncate(stripMarkdown(block.text), 360)} "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
</div> compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
) : ( )}
<MarkdownBody className="text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> >
{block.text} {block.text}
</MarkdownBody> </MarkdownBody>
)}
{block.streaming && ( {block.streaming && (
<div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground"> <div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground">
<span className="relative flex h-1.5 w-1.5"> <span className="relative flex h-1.5 w-1.5">
@@ -544,19 +553,22 @@ function TranscriptMessageBlock({
function TranscriptThinkingBlock({ function TranscriptThinkingBlock({
block, block,
density, density,
className,
}: { }: {
block: Extract<TranscriptBlock, { type: "thinking" }>; block: Extract<TranscriptBlock, { type: "thinking" }>;
density: TranscriptDensity; density: TranscriptDensity;
className?: string;
}) { }) {
return ( return (
<div <MarkdownBody
className={cn( className={cn(
"whitespace-pre-wrap break-words italic text-foreground/70", "italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6", density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
className,
)} )}
> >
{block.text} {block.text}
</div> </MarkdownBody>
); );
} }
@@ -956,6 +968,7 @@ export function RunTranscriptView({
collapseStdout = false, collapseStdout = false,
emptyMessage = "No transcript yet.", emptyMessage = "No transcript yet.",
className, className,
thinkingClassName,
}: RunTranscriptViewProps) { }: RunTranscriptViewProps) {
const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]); const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]);
const visibleBlocks = limit ? blocks.slice(-limit) : blocks; const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
@@ -985,7 +998,9 @@ export function RunTranscriptView({
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")} className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
> >
{block.type === "message" && <TranscriptMessageBlock block={block} density={density} />} {block.type === "message" && <TranscriptMessageBlock block={block} density={density} />}
{block.type === "thinking" && <TranscriptThinkingBlock block={block} density={density} />} {block.type === "thinking" && (
<TranscriptThinkingBlock block={block} density={density} className={thinkingClassName} />
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />} {block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />} {block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "stdout" && ( {block.type === "stdout" && (

View File

@@ -59,6 +59,7 @@ import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared"; import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils"; import { agentRouteRef } from "../lib/utils";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = { const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
@@ -92,11 +93,11 @@ function redactEnvValue(key: string, value: unknown): string {
} }
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE; if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
if (typeof value === "string") return value; if (typeof value === "string") return redactHomePathUserSegments(value);
try { try {
return JSON.stringify(value); return JSON.stringify(redactHomePathUserSegmentsInValue(value));
} catch { } catch {
return String(value); return redactHomePathUserSegments(String(value));
} }
} }
@@ -2023,7 +2024,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const adapterInvokePayload = useMemo(() => { const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke"); const evt = events.find((e) => e.eventType === "adapter.invoke");
return asRecord(evt?.payload ?? null); return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
}, [events]); }, [events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@@ -2096,8 +2097,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="text-xs text-muted-foreground mb-1">Prompt</div> <div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap"> <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string" {typeof adapterInvokePayload.prompt === "string"
? adapterInvokePayload.prompt ? redactHomePathUserSegments(adapterInvokePayload.prompt)
: JSON.stringify(adapterInvokePayload.prompt, null, 2)} : JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
</pre> </pre>
</div> </div>
)} )}
@@ -2105,7 +2106,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Context</div> <div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap"> <pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(adapterInvokePayload.context, null, 2)} {JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
</pre> </pre>
</div> </div>
)} )}
@@ -2189,14 +2190,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.error && ( {run.error && (
<div className="text-xs text-red-600 dark:text-red-200"> <div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span> <span className="text-red-700 dark:text-red-300">Error: </span>
{run.error} {redactHomePathUserSegments(run.error)}
</div> </div>
)} )}
{run.stderrExcerpt && run.stderrExcerpt.trim() && ( {run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div> <div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div> <div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100"> <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stderrExcerpt} {redactHomePathUserSegments(run.stderrExcerpt)}
</pre> </pre>
</div> </div>
)} )}
@@ -2204,7 +2205,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div> <div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100"> <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(run.resultJson, null, 2)} {JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
</pre> </pre>
</div> </div>
)} )}
@@ -2212,7 +2213,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div> <div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div> <div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100"> <pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stdoutExcerpt} {redactHomePathUserSegments(run.stdoutExcerpt)}
</pre> </pre>
</div> </div>
)} )}
@@ -2238,7 +2239,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{evt.stream ? `[${evt.stream}]` : ""} {evt.stream ? `[${evt.stream}]` : ""}
</span> </span>
<span className={cn("break-all", color)}> <span className={cn("break-all", color)}>
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")} {evt.message
? redactHomePathUserSegments(evt.message)
: evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
: ""}
</span> </span>
</div> </div>
); );

View File

@@ -12,11 +12,12 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { ApprovalCard } from "../components/ApprovalCard"; import { ApprovalCard } from "../components/ApprovalCard";
import { IssueRow } from "../components/IssueRow";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusIcon } from "../components/StatusIcon";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -350,6 +351,15 @@ export function Inbox() {
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)), () => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
[heartbeatRuns, dismissed], [heartbeatRuns, dismissed],
); );
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of heartbeatRuns ?? []) {
if (run.status !== "running" && run.status !== "queued") continue;
const issueId = readIssueIdFromRun(run);
if (issueId) ids.add(issueId);
}
return ids;
}, [heartbeatRuns]);
const allApprovals = useMemo( const allApprovals = useMemo(
() => () =>
@@ -546,36 +556,37 @@ export function Inbox() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
{canMarkAllRead && ( {canMarkAllRead && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8" className="h-8 shrink-0"
onClick={() => markAllReadMutation.mutate(unreadIssueIds)} onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
disabled={markAllReadMutation.isPending} disabled={markAllReadMutation.isPending}
> >
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"} {markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button> </Button>
)} )}
</div>
{tab === "all" && ( {tab === "all" && (
<> <div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Select <Select
value={allCategoryFilter} value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)} onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
@@ -608,9 +619,8 @@ export function Inbox() {
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
</> </div>
)} )}
</div>
</div> </div>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>} {approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
@@ -800,64 +810,52 @@ export function Inbox() {
<> <>
{showSeparatorBefore("issues_i_touched") && <Separator />} {showSeparatorBefore("issues_i_touched") && <Separator />}
<div> <div>
<div className="divide-y divide-border border border-border"> <div>
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { {(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id);
return ( return (
<Link <IssueRow
key={issue.id} key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`} issue={issue}
state={issueLinkState} issueLinkState={issueLinkState}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" desktopMetaLeading={(
> <>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center"> <span className="hidden sm:inline-flex">
{(isUnread || isFading) ? ( <PriorityIcon priority={issue.priority} />
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span> </span>
) : ( <span className="hidden shrink-0 sm:inline-flex">
<span className="inline-flex h-4 w-4" aria-hidden="true" /> <StatusIcon status={issue.status} />
)} </span>
</span> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
<span className="inline-flex shrink-0 self-center"><PriorityIcon priority={issue.priority} /></span> </span>
<span className="inline-flex shrink-0 self-center"><StatusIcon status={issue.status} /></span> {liveIssueIds.has(issue.id) && (
<span className="shrink-0 self-center text-xs font-mono text-muted-foreground"> <span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
{issue.identifier ?? issue.id.slice(0, 8)} <span className="relative flex h-2 w-2">
</span> <span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="min-w-0 flex-1 text-sm"> <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
<span className="line-clamp-2 min-w-0 sm:line-clamp-1 sm:block sm:truncate"> </span>
{issue.title} <span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
</span> Live
</span> </span>
<span className="hidden shrink-0 self-center text-xs text-muted-foreground sm:block"> </span>
{issue.lastExternalCommentAt )}
</>
)}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}` ? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`} : `updated ${timeAgo(issue.updatedAt)}`
</span> }
</Link> unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
onMarkRead={() => markReadMutation.mutate(issue.id)}
trailingMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
/>
); );
})} })}
</div> </div>