diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index d1ca0f4cf60e5..bbfd7f295ec2a 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -178,6 +178,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
/* The composer's elevation: a soft drop pulled in under the surface, shared
by the composer, its context strip and attached banners. */
--shadow-composer: 0 12px 28px -18px rgb(0 0 0 / 40%);
+ --shadow-composer-bash: 0 0 32px 4px rgb(0 0 0 / 25%);
--shadow-composer-dark: 0 14px 32px -18px rgb(0 0 0 / 75%);
/* Dark-mode shade where an attached surface meets the composer: 10px of
shadow fading away from the seam. Inline, so the offsets resolve on the
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 7da6567dbbed2..90fa788537ca7 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -608,6 +608,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
entry.detail = message;
}
}
+ // Composer Bash output is shown whole on its message. The client projection
+ // trims `data.rawOutput` to one line, so read the untrimmed `detail`.
+ const composerBashOutput = payload?.detail;
+ if (toolCallId?.startsWith("composer-bash:") && typeof composerBashOutput === "string") {
+ entry.detail = composerBashOutput;
+ }
if (viewedImagePath) {
entry.viewedImagePath = viewedImagePath;
}
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 85d2b6f099e45..8fec9747ea446 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -153,6 +153,19 @@ Provider commands must start the message to run. T3 Code commands such as
Send `/compact` in an existing conversation to reduce context usage when the
provider supports it. Web and desktop also offer compaction from the context meter.
+## One-off Bash commands
+
+Send `!` followed by a command, such as `!npm run build`, to run it in the thread's
+workspace on the connected environment. T3 Code shows its result in the conversation
+and sends the command, exit status, and output to the selected agent as context.
+Failed commands include their output so the agent can help diagnose the failure.
+
+Commands require Bash and run without an interactive terminal or shell startup files.
+Each command has a 60-second limit and captures up to 16 KiB each of stdout and stderr.
+Truncated output and empty output are labeled. Stop cancels a running command; a
+timeout ends it without preserving partial output. Known credentials are redacted,
+but avoid commands that print secrets.
+
## Context in your message
Context you attach lands where your cursor is, as a chip inside your text: a terminal excerpt,
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index dc8cc4ae76ea8..04f27192ded15 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./composer-bash": {
+ "types": "./src/composerBash.ts",
+ "default": "./src/composerBash.ts"
+ },
"./device/stream": {
"types": "./src/device/stream.ts",
"default": "./src/device/stream.ts"
diff --git a/packages/client-runtime/src/composerBash.test.ts b/packages/client-runtime/src/composerBash.test.ts
new file mode 100644
index 0000000000000..2ce36d2c8b3c6
--- /dev/null
+++ b/packages/client-runtime/src/composerBash.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vite-plus/test";
+import { indexComposerBashCommands, parseComposerBashOutput } from "./composerBash.ts";
+
+const activity = {
+ toolCallId: "composer-bash:message-1",
+ itemType: "command_execution",
+ sourceActivityKind: "tool.updated",
+ toolLifecycleStatus: "inProgress",
+};
+
+describe("composer Bash message results", () => {
+ it("links server-confirmed commands to their exact message id", () => {
+ const commands = indexComposerBashCommands([activity]);
+ expect(commands.get("message-1")).toEqual({ status: "running", output: null });
+ expect(commands.has("message-2")).toBe(false);
+ });
+
+ it("replaces running state with the corresponding result", () => {
+ const commands = indexComposerBashCommands([
+ activity,
+ {
+ ...activity,
+ sourceActivityKind: "tool.completed",
+ toolLifecycleStatus: "completed",
+ detail: "Exit code: 0\nstdout:\nfile.txt",
+ },
+ ]);
+ expect(commands.get("message-1")).toEqual({
+ status: "completed",
+ output: "Exit code: 0\nstdout:\nfile.txt",
+ });
+ });
+
+ it("keeps failed output distinct from successful entry", () => {
+ expect(
+ indexComposerBashCommands([
+ {
+ ...activity,
+ sourceActivityKind: "tool.completed",
+ toolLifecycleStatus: "failed",
+ detail: "Bash could not be started.",
+ },
+ ]).get("message-1"),
+ ).toEqual({ status: "failed", output: "Bash could not be started." });
+ });
+
+ it("ignores ordinary tools and unconfirmed lifecycle states", () => {
+ expect(
+ indexComposerBashCommands([
+ { ...activity, toolCallId: "provider-tool-1" },
+ { ...activity, sourceActivityKind: "tool.started" },
+ { ...activity, itemType: "file_change" },
+ {
+ toolCallId: activity.toolCallId,
+ itemType: activity.itemType,
+ sourceActivityKind: activity.sourceActivityKind,
+ },
+ ]).size,
+ ).toBe(0);
+ });
+});
+
+describe("parseComposerBashOutput", () => {
+ it("splits exit code, stdout and stderr", () => {
+ expect(parseComposerBashOutput("Exit code: 1\nstdout:\na\nb\nstderr:\nboom")).toEqual({
+ exitCode: "1",
+ stdout: "a\nb",
+ stderr: "boom",
+ });
+ expect(parseComposerBashOutput("Exit code: 2\nstderr:\nboom")).toEqual({
+ exitCode: "2",
+ stdout: "",
+ stderr: "boom",
+ });
+ expect(parseComposerBashOutput("Exit code: 0\n(no output)")).toEqual({
+ exitCode: "0",
+ stdout: "",
+ stderr: "",
+ });
+ expect(parseComposerBashOutput("Bash command timed out.")).toEqual({
+ exitCode: null,
+ stdout: "Bash command timed out.",
+ stderr: "",
+ });
+ });
+});
diff --git a/packages/client-runtime/src/composerBash.ts b/packages/client-runtime/src/composerBash.ts
new file mode 100644
index 0000000000000..0fdd57793a233
--- /dev/null
+++ b/packages/client-runtime/src/composerBash.ts
@@ -0,0 +1,68 @@
+export interface ComposerBashCommand {
+ readonly status: "running" | "completed" | "failed";
+ readonly output: string | null;
+}
+
+export const composerBashStatusLabel = (command: ComposerBashCommand): string =>
+ command.status === "running"
+ ? "Bash · Running"
+ : command.status === "completed"
+ ? "Bash · Completed"
+ : "Bash · Failed";
+
+export interface ComposerBashOutput {
+ readonly exitCode: string | null;
+ readonly stdout: string;
+ readonly stderr: string;
+}
+
+/** Splits the server's `Exit code / stdout / stderr` result; other text is kept as stdout. */
+export function parseComposerBashOutput(output: string): ComposerBashOutput {
+ const match = /^Exit code: (\S+)\n?/.exec(output);
+ if (!match) return { exitCode: null, stdout: output, stderr: "" };
+ const body = output.slice(match[0].length);
+ const stderrIndex = body.startsWith("stderr:\n") ? 0 : body.indexOf("\nstderr:\n");
+ const stdoutPart = stderrIndex === -1 ? body : body.slice(0, Math.max(stderrIndex, 0));
+ const stderr = stderrIndex === -1 ? "" : body.slice(stderrIndex + (stderrIndex === 0 ? 8 : 9));
+ const stdout = stdoutPart.startsWith("stdout:\n")
+ ? stdoutPart.slice(8)
+ : stdoutPart === "(no output)"
+ ? ""
+ : stdoutPart;
+ return { exitCode: match[1]!, stdout, stderr };
+}
+
+/** Composer Bash activities render on their user message, not as separate work rows. */
+export const isComposerBashEntry = (entry: { readonly toolCallId?: string }): boolean =>
+ entry.toolCallId?.startsWith("composer-bash:") === true;
+
+/** Only server activities with the composer message identity confirm Bash execution. */
+export function indexComposerBashCommands(
+ entries: ReadonlyArray<{
+ readonly toolCallId?: string;
+ readonly itemType?: string;
+ readonly sourceActivityKind?: string;
+ readonly toolLifecycleStatus?: string;
+ readonly detail?: string;
+ }>,
+): ReadonlyMap {
+ const commands = new Map();
+ for (const entry of entries) {
+ if (!entry.toolCallId || !isComposerBashEntry(entry) || entry.itemType !== "command_execution")
+ continue;
+ const status =
+ entry.sourceActivityKind === "tool.completed"
+ ? entry.toolLifecycleStatus === "completed"
+ ? "completed"
+ : "failed"
+ : entry.sourceActivityKind === "tool.updated" && entry.toolLifecycleStatus === "inProgress"
+ ? "running"
+ : null;
+ if (status)
+ commands.set(entry.toolCallId.slice("composer-bash:".length), {
+ status,
+ output: entry.detail ?? null,
+ });
+ }
+ return commands;
+}