Clipboard commands, read-only Git changes, and turn-level token throughput.

A local Claude Code Mod with clipboard/Git commands, a throughput display, and structured validation. Tested on 2.1.285 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Bootstrap's --tools claude selection links it into the Claude configuration directory and enables Mods; restart Claude Code afterwards. It registers one model tool for validation. It does not call models, approve permissions, or fetch quota APIs.
| Command | Action |
|---|---|
/clip text | Copy literal text. |
/clip @path | Copy a file's text. Relative paths resolve under the session directory. Paths with spaces use the entire text after @, without shell quoting. |
/copy-all | Copy user and assistant text from this thread, without tool metadata. |
/changes | Show Git status, staged/unstaged diff stats, untracked files, and five recent commits. |
/tps | Show the last main turn's output tokens per elapsed second. |
/validate {"action":"test","command":"…"} | Run the validation tool directly, without a model request. |
Clipboard commands accept only composer, Remote Control bridge, or explicit CLI/SDK origins. Other plugins, background jobs, peer messages, and unclassified origins are refused before reading files. A remote or headless surface without clipboard support reports failure; there is no shell fallback. Native terminal clipboard support may use OSC 52, whose delivery cannot be acknowledged.
Clipboard content is limited to 1,048,576 JavaScript characters. /copy-all refuses a thread at the native 4,096-row limit because its completeness is unknown, and refuses oversized output rather than silently truncating it. It copies visible message text, not a redacted export: do not copy a sensitive conversation unintentionally.
Git queries use argument arrays, a five-second per-process timeout, and bounded output. External diff/text conversion, signature verification, fsmonitor helpers, and optional index refreshes are disabled. /changes inspects the whole current workspace, not only this agent's edits, and does not track last-turn filenames.
The TPS band keeps existing above-prompt content and stays out of question dialogs and subagent transcript views. Session-local state survives hot reload and resets with native session state. It ignores child and stale completions; a versioned write prevents an old result from replacing a newer turn. The rate includes reasoning output, tool execution, and network waits; it is not model decoding speed. Aborted turns are labelled interrupted.
mcp__trial-tools__project_validate accepts action (test, lint, or typecheck), optional command and cwd, and optional timeoutMs. Prefer a known, non-mutating command. Do not use install, fix, format-write, deployment, publication, or arbitrary chore commands. Command text is limited to 8,192 characters and directories to 4,096. Timeout defaults to 120 seconds; integer values from 1 to 300 seconds are accepted, not clamped.
/validate {"action":"test","command":"bun test","timeoutMs":30000}
The adapter calls native Bash with normal permission and sandbox checks. Denial prevents execution; it does not add allow rules or disable sandboxing. A Bun helper (hooks/project-validate.ts) runs the selected command, bounds retained output to 30,000 bytes plus a truncation marker, and terminates its owned process group on timeout or interruption. Commands still have local user permissions: this is not a read-only sandbox. Checks must not daemonize. Detached descendants or forced SIGKILL of the helper can bypass graceful cleanup.
Explicit commands use the requested directory, relative to the session directory when needed. Without a command, detection walks at most 32 parent directories within the Git repository and selects npm/pnpm/Yarn/Bun, Cargo, Go, Python, Make, or Just. An unrecognized project returns unsupported rather than success. UV detection disables dependency syncing, lockfile updates, and Python downloads.
The tool returns JSON text with outcome, exit code, elapsed time, bounded output, diagnostics, and a versioned receipt. Each completed attempt records its command, directory, timestamps, unique ID, verifier, and before/after workspace fingerprints. Fingerprints hash repository root, HEAD, index entries, and raw tracked/nonignored untracked regular-file bytes and modes. Reading raw bytes avoids Git clean/textconv helpers and line-ending normalization. Limits are 1 MiB per Git response, 1,000 workspace files/20 MiB total content, and 100 untracked files/10 MiB untracked content. Symlinks, submodules, unreadable files, changing files, and over-limit workspaces produce unavailable fingerprints.
workspaceStable: true means the two observations matched, not that the workspace was locked. Ignored files, dependencies, and environment are excluded. Changed or unavailable fingerprints appear as explicit gaps; command exit zero alone is not acceptance. Denial, cancellation, or a failed launch can return no receipt. Receipts are not cached, automatically reused, or written into the repository. Child receipts remain worker evidence; the parent owns acceptance. See the execution and receipt decision.
Load this directory with claude --plugin-dir PATH, with the early-access flag enabled on 2.1.285, to generate .claude-plugin/types/ for your installed version. Those declarations are the API authority and remain ignored. Check types before running tests:
tsc -p PATH --noEmit
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate --strict PATH
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test PATH
Native tests stub clipboard and Git APIs; they do not change the real clipboard, access credentials, or make model requests. The runner and the real-CLI journey are tested from claude/tests with bun test. Drawing tests validate terminal and desktop trees, not the app's paint. Inspect the band in a fresh interactive session after changing its layout.
hooks/register.ts 141 lines1import type { EngineInterface, Register, SessionMessage, TurnCompleteInput } from "claude-code";
2import type { TpsState } from "../types/index.js";
3import { registerValidation, VALIDATION_SPEC } from "./validation.js";
4
5export const MAX_COPY_CHARS = 1_048_576;
6export const MAX_THREAD_ROWS = 4096;
7const MAX_GIT_CHARS = 32_768;
8const TPS = { plugin: "trial-tools", key: "tps" } as const;
9
10export function formatThread(messages: readonly SessionMessage[]): string {
11 if (messages.length >= MAX_THREAD_ROWS) throw new Error("Thread reaches the native row limit; export it instead.");
12 const parts: string[] = [];
13 let length = 0;
14 for (const message of messages) {
15 if (!message.text.trim()) continue;
16 const part = `## ${message.role}\n\n${message.text}\n`;
17 length += part.length;
18 if (length > MAX_COPY_CHARS) throw new Error("Thread exceeds the clipboard size limit.");
19 parts.push(part);
20 }
21 const text = parts.join("\n");
22 if (text.length > MAX_COPY_CHARS) throw new Error("Thread exceeds the clipboard size limit.");
23 return text;
24}
25
26export function throughput(event: TurnCompleteInput): TpsState {
27 const outputTokens = event.usage?.output_tokens;
28 if (!Number.isSafeInteger(outputTokens) || outputTokens === undefined || outputTokens < 0) return { phase: "unavailable" };
29 if (!Number.isSafeInteger(event.durationMs) || event.durationMs <= 0) return { phase: "unavailable" };
30 return { phase: "complete", outputTokens, durationMs: event.durationMs, interrupted: event.isAborted };
31}
32
33export function tpsLabel(state: TpsState | undefined): string {
34 if (state === undefined) return "out tok/s: idle";
35 switch (state.phase) {
36 case "working": return "out tok/s: working";
37 case "unavailable": return "out tok/s: n/a";
38 case "complete": {
39 const rate = state.outputTokens * 1000 / state.durationMs;
40 return `out tok/s ${rate.toFixed(1)} (${state.outputTokens}${state.interrupted ? ", interrupted" : ""})`;
41 }
42 default: {
43 const impossible: never = state;
44 throw new Error(`Unknown throughput state: ${String(impossible)}`);
45 }
46 }
47}
48
49async function copyText($: EngineInterface, text: string) {
50 if (!text.length) return { text: "Nothing to copy.", exitCode: 1 };
51 if (text.length > MAX_COPY_CHARS) return { text: "Clipboard input exceeds the 1 Mi-character limit.", exitCode: 1 };
52 const result = await $.ui.copy({ text });
53 return result.isCopied
54 ? { text: "Copied to clipboard.", exitCode: 0 }
55 : { text: "Clipboard unavailable on this surface; use an interactive terminal or desktop session.", exitCode: 1 };
56}
57
58async function git($: EngineInterface, cwd: string, args: readonly string[]): Promise<string> {
59 const result = await $.process.run(["git", "--no-pager", "--no-optional-locks", "-c", "color.ui=false", "-c", "core.fsmonitor=false", "-c", "diff.autoRefreshIndex=false", ...args], { cwd, timeoutMs: 5000 });
60 if (result.exitCode !== 0) throw new Error("Git inspection failed; check the repository and Git installation.");
61 if (result.isStdoutTruncated || result.stdout.length > MAX_GIT_CHARS) throw new Error("Git inspection exceeds the display limit; inspect it in the terminal.");
62 return result.stdout.replace(/\r?\n$/, "") || "none";
63}
64
65export const register: Register = (on) => {
66 registerValidation(on);
67 on("session.start", async ($, event, next) => {
68 const result = await next(event);
69 await $.tool.register(VALIDATION_SPEC);
70 await $.command.register({ name: "validate", description: "Run structured validation: /validate {\"action\":\"test\",\"command\":\"…\"}", immediate: true });
71 await $.command.register({ name: "clip", description: "Copy text or @file contents to the clipboard", argumentHint: "text | @path" });
72 await $.command.register({ name: "copy-all", description: "Copy this thread's user/assistant text to the clipboard" });
73 await $.command.register({ name: "changes", description: "Read-only Git status, diff stats, untracked files, and recent commits" });
74 await $.command.register({ name: "tps", description: "Show last turn's output tokens per wall-clock second", immediate: true });
75 return result;
76 });
77
78 on("command.run", { command: "clip" }, async ($, event) => {
79 if (!["composer", "bridge", "sdk"].includes(event.origin.kind)) return { text: "Clipboard commands require a direct user or CLI invocation.", exitCode: 1 };
80 const input = event.args.trim();
81 if (!input) return { text: "Usage: /clip text or /clip @path", exitCode: 1 };
82 if (input.startsWith("@")) {
83 const path = input.slice(1).trim();
84 if (!path) return { text: "Usage: /clip @path", exitCode: 1 };
85 return copyText($, await $.fs.read(path));
86 }
87 return copyText($, input);
88 }).catch(() => ({ text: "Copy failed; no clipboard update was confirmed.", exitCode: 1 }));
89
90 on("command.run", { command: "copy-all" }, async ($, event) => {
91 if (!["composer", "bridge", "sdk"].includes(event.origin.kind)) return { text: "Clipboard commands require a direct user or CLI invocation.", exitCode: 1 };
92 if (event.args.trim()) return { text: "Usage: /copy-all", exitCode: 1 };
93 return copyText($, formatThread(await $.session.messages()));
94 }).catch(() => ({ text: "Thread copy failed; no clipboard update was confirmed. Check thread size and clipboard availability.", exitCode: 1 }));
95
96 on("command.run", { command: "changes" }, async ($, event) => {
97 if (event.args.trim()) return { text: "Usage: /changes", exitCode: 1 };
98 const cwd = await $.session.cwd();
99 const root = await git($, cwd, ["rev-parse", "--show-toplevel"]);
100 const sections = [
101 "# Changes", `Repo: ${root}`,
102 "\n## Status", await git($, cwd, ["status", "--short", "--untracked-files=normal"]),
103 "\n## Staged diff stat", await git($, cwd, ["diff", "--no-ext-diff", "--no-textconv", "--cached", "--stat"]),
104 "\n## Unstaged diff stat", await git($, cwd, ["diff", "--no-ext-diff", "--no-textconv", "--stat"]),
105 "\n## Untracked files", await git($, cwd, ["ls-files", "--others", "--exclude-standard"]),
106 "\n## Recent commits", await git($, cwd, ["log", "--no-show-signature", "--oneline", "-5"]),
107 ];
108 const text = sections.join("\n");
109 if (text.length > MAX_GIT_CHARS) return { text: "Git summary exceeds the display limit; inspect it in the terminal.", exitCode: 1 };
110 return { text, exitCode: 0 };
111 }).catch(() => ({ text: "Git inspection failed; check the repository, Git installation, and output size.", exitCode: 1 }));
112
113 on("turn.start", async ($, event, next) => {
114 const result = await next(event);
115 await $.state.set(TPS, { phase: "working", turnId: event.turnId });
116 return result;
117 });
118
119 on("turn.complete", async ($, event, next) => {
120 const result = await next(event);
121 if (event.agentId !== undefined) return result;
122 const held = await $.state.get(TPS);
123 if (held.value?.phase !== "working" || held.value.turnId !== event.turnId) return result;
124 await $.state.set(TPS, throughput(event), { ifVersion: held.version });
125 return result;
126 });
127
128 on("command.run", { command: "tps" }, async ($, event) => {
129 if (event.args.trim()) return { text: "Usage: /tps", exitCode: 1 };
130 return { text: tpsLabel((await $.state.get(TPS)).value), exitCode: 0 };
131 });
132
133 on("ui.render", { component: "AbovePrompt" }, async ($, event, next) => {
134 if (event.props.hasSurvey || event.props.view?.agentId !== undefined) return next(event);
135 const label = tpsLabel((await $.state.get(TPS)).value);
136 const base = await next(event);
137 const { Box, Text } = $.ui.resolve(event);
138 return Box({ flexDirection: "column", children: [base, Text({ dimColor: true, children: label })] });
139 });
140};
141hooks/validation.ts 98 lines1import type { EngineInterface, On } from "claude-code";
2
3export const VALIDATION_TOOL = "mcp__trial-tools__project_validate";
4export const ACTIONS = ["test", "lint", "typecheck"] as const;
5type Action = typeof ACTIONS[number];
6export type ValidationInput = { action: Action; command?: string; cwd?: string; timeoutMs: number };
7const MAX_COMMAND = 8192;
8const MAX_CWD = 4096;
9const MIN_TIMEOUT = 1000;
10const MAX_TIMEOUT = 300000;
11const DEFAULT_TIMEOUT = 120000;
12
13export const INPUT_SCHEMA = {
14 type: "object", additionalProperties: false, required: ["action"],
15 properties: {
16 action: { type: "string", enum: ACTIONS },
17 command: { type: "string", minLength: 1, maxLength: MAX_COMMAND, description: "Explicit non-mutating test/lint/typecheck command. Never install, fix, format-write, deploy, or publish." },
18 cwd: { type: "string", minLength: 1, maxLength: MAX_CWD, description: "Project directory; relative paths resolve under the session directory. Explicit commands use this exact directory." },
19 timeoutMs: { type: "integer", minimum: MIN_TIMEOUT, maximum: MAX_TIMEOUT, default: DEFAULT_TIMEOUT },
20 },
21};
22
23export const VALIDATION_SPEC = { name: "project_validate", description: "Run bounded project tests, lint, or typechecks through native Bash permission/sandbox checks, with structured diagnostics and before/after workspace receipts. Prefer an explicit known command; otherwise detect the project runner. Only non-mutating validation: never install, fix, format-write, deploy, publish, or run arbitrary chores. Commands have local user permissions; this is not a read-only sandbox. Receipts are observations, not authority or proof of unchanged ignored dependencies/environment.", inputSchema: INPUT_SCHEMA };
24
25function textParameter(value: unknown, name: string, limit: number): string | undefined {
26 if (value === undefined) return undefined;
27 if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0")) throw new Error(`${name} must be nonempty text of at most ${limit} characters without NUL`);
28 return value;
29}
30
31export function parseValidationInput(value: unknown): ValidationInput {
32 if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("validation input must be an object");
33 const fields = value as Record<string, unknown>;
34 const unknown = Object.keys(fields).find(key => !["action", "command", "cwd", "timeoutMs", "tool", "tool_use_id", "agentId"].includes(key));
35 if (unknown !== undefined) throw new Error(`unknown validation field: ${unknown}`);
36 const action = ACTIONS.find(candidate => candidate === fields.action);
37 if (action === undefined) throw new Error("action must be test, lint, or typecheck");
38 const command = textParameter(fields.command, "command", MAX_COMMAND);
39 const cwd = textParameter(fields.cwd, "cwd", MAX_CWD);
40 const timeoutMs = fields.timeoutMs === undefined ? DEFAULT_TIMEOUT : fields.timeoutMs;
41 if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT || timeoutMs > MAX_TIMEOUT) throw new Error(`timeoutMs must be an integer in ${MIN_TIMEOUT}..${MAX_TIMEOUT}`);
42 return { action, command, cwd, timeoutMs };
43}
44
45export function shellQuote(text: string): string {
46 if (text.includes("\0")) throw new Error("shell argument contains NUL");
47 return "'" + text.replace(/'/g, "'\\''") + "'";
48}
49
50function record(value: unknown): Record<string, unknown> {
51 if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("native result must be an object");
52 return value as Record<string, unknown>;
53}
54
55export async function runValidation($: EngineInterface, input: ValidationInput, agentId?: string) {
56 const cwd = input.cwd ?? await $.session.cwd();
57 const arguments_ = ["bun", `${$.plugin.root}/hooks/project-validate.ts`, "--action", input.action, "--cwd", cwd, "--timeout-ms", String(input.timeoutMs), "--actor", agentId === undefined ? "root-or-user" : `subagent:${agentId}`];
58 if (input.command !== undefined) arguments_.push("--command", input.command);
59 // The built-in tool supplies permission checks and sandboxing; process.run would bypass that path.
60 const bash = await $.tool.call({ tool: "Bash", command: arguments_.map(shellQuote).join(" "), timeout: input.timeoutMs + 60000, run_in_background: false, description: `Run bounded project ${input.action} with workspace evidence` });
61 if (bash.deny !== undefined) return { outcome: "denied", error: bash.deny, verificationReceipt: null };
62 const returned = record(bash.result);
63 if (typeof returned.backgroundTaskId === "string") {
64 const stopped = await $.tool.call({ tool: "TaskStop", task_id: returned.backgroundTaskId });
65 return { outcome: "execution_error", error: "Native Bash backgrounded validation; result not observed", verificationReceipt: null, residualGaps: stopped.deny !== undefined || stopped.isError ? ["Background task stop was refused or failed; inspect the exact task ID", returned.backgroundTaskId] : [] };
66 }
67 if (returned.interrupted === true) return { outcome: "cancelled", error: "Native Bash interrupted validation", verificationReceipt: null };
68 if (bash.isError || typeof returned.stdout !== "string") return { outcome: "execution_error", error: bash.text ?? "Native Bash failed to launch validation", verificationReceipt: null };
69 const result = record(JSON.parse(returned.stdout));
70 if (!["passed", "failed", "timed_out", "cancelled", "execution_error", "unsupported"].includes(String(result.outcome))) throw new Error("validation runner returned an unknown outcome");
71 if (result.outcome !== "execution_error" && result.outcome !== "cancelled" && record(result.verificationReceipt).version !== 1) throw new Error("validation receipt version is unsupported");
72 return result;
73}
74
75export function registerValidation(on: On): void {
76 on("command.run", { command: "validate" }, async ($, event) => {
77 try {
78 const input = parseValidationInput(JSON.parse(event.args));
79 const result = await $.tool.call({ tool: VALIDATION_TOOL, ...input });
80 return { text: result.deny === undefined && typeof result.result === "string" ? result.result : JSON.stringify({ outcome: "denied", error: result.deny }), presentation: "text" as const };
81 } catch (error) {
82 return { text: `trial-tools: /validate requires a JSON object with action test, lint, or typecheck. ${String(error)}`, presentation: "text" as const };
83 }
84 });
85 on("tool.call", { tool: VALIDATION_TOOL }, async ($, event) => {
86 let input: ValidationInput;
87 try { input = parseValidationInput(event); }
88 catch (error) { return { result: JSON.stringify({ outcome: "invalid_input", error: String(error), verificationReceipt: null }), isError: true }; }
89 try {
90 const result = await runValidation($, input, event.agentId);
91 if (result.outcome === "passed") return { result: JSON.stringify(result) };
92 return { result: JSON.stringify(result), isError: true as const };
93 } catch (error) {
94 return { result: JSON.stringify({ outcome: "execution_error", error: String(error), verificationReceipt: null }), isError: true };
95 }
96 });
97}
98types/index.d.ts 11 lines1export type TpsState =
2 | { phase: "working"; turnId: string }
3 | { phase: "unavailable" }
4 | { phase: "complete"; outputTokens: number; durationMs: number; interrupted: boolean };
5
6declare module "claude-code" {
7 interface PluginState {
8 "trial-tools": { tps: TpsState };
9 }
10}
11