Skip to content

Commit d1777ea

Browse files
only21milclaudemaria-rcks
authored
fix(claude): stream live command output on Linux when TMPDIR is set (#24176)
Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: maria-rcks <[email protected]>
1 parent e9dd8a6 commit d1777ea

5 files changed

Lines changed: 180 additions & 7 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8385,6 +8385,116 @@ describe("ClaudeAdapterV2 background wake turns", () => {
83858385
),
83868386
);
83878387

8388+
it.effect.skipIf(process.getuid === undefined)(
8389+
"tails foreground Bash output under the physical cwd the query launched in",
8390+
() =>
8391+
Effect.scoped(
8392+
Effect.gen(function* () {
8393+
const fileSystem = yield* FileSystem.FileSystem;
8394+
const path = yield* Path.Path;
8395+
const root = yield* fileSystem.makeTempDirectoryScoped({
8396+
prefix: "t3-claude-task-output-cwd-",
8397+
});
8398+
const physicalCwd = path.join(root, "real", "project");
8399+
const launchCwd = path.join(root, "link");
8400+
yield* fileSystem.makeDirectory(physicalCwd, { recursive: true });
8401+
yield* fileSystem.symlink(physicalCwd, launchCwd);
8402+
// The session opens in /workspace; a relative TMPDIR puts Claude's
8403+
// task files under whichever cwd the CLI was launched in. Linux,
8404+
// because Claude Code's macOS build ignores TMPDIR.
8405+
const harness = yield* makeWakeHarnessWithOptions({ environment: { TMPDIR: "../tmp" } });
8406+
const now = yield* DateTime.now;
8407+
const toolUseId = "toolu_foreground_bash";
8408+
const taskId = "foreground-bash-task";
8409+
const tasks = path.join(
8410+
root,
8411+
"real",
8412+
"tmp",
8413+
`claude-${process.getuid?.()}`,
8414+
"project",
8415+
WAKE_NATIVE_SESSION,
8416+
"tasks",
8417+
);
8418+
yield* fileSystem.makeDirectory(tasks, { recursive: true });
8419+
yield* fileSystem.writeFileString(path.join(tasks, `${taskId}.output`), "line 1\n");
8420+
8421+
yield* harness.runtime.startTurn(
8422+
makeClaudeTestTurnInput({
8423+
threadId: harness.threadId,
8424+
providerThread: harness.providerThread,
8425+
now,
8426+
attemptId: RunAttemptId.make("attempt-claude-task-output-cwd"),
8427+
text: "Run the build.",
8428+
attachments: [],
8429+
runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: launchCwd },
8430+
}),
8431+
);
8432+
assert.equal(harness.getOpenedOptions()?.cwd, launchCwd);
8433+
for (const frame of [
8434+
claudeSdkFrame({
8435+
type: "assistant",
8436+
message: {
8437+
model: "claude-sonnet-4-6",
8438+
id: "msg_foreground_bash",
8439+
type: "message",
8440+
role: "assistant",
8441+
content: [
8442+
{
8443+
type: "tool_use",
8444+
id: toolUseId,
8445+
name: "Bash",
8446+
input: { command: "vp run build", description: "Build" },
8447+
},
8448+
],
8449+
stop_reason: null,
8450+
stop_sequence: null,
8451+
usage: { input_tokens: 1, output_tokens: 1 },
8452+
},
8453+
parent_tool_use_id: null,
8454+
uuid: "00000000-0000-4000-8000-000000000a01",
8455+
session_id: WAKE_NATIVE_SESSION,
8456+
}),
8457+
claudeSdkFrame({
8458+
type: "system",
8459+
subtype: "task_started",
8460+
task_id: taskId,
8461+
tool_use_id: toolUseId,
8462+
description: "vp run build",
8463+
is_backgrounded: false,
8464+
task_type: "local_bash",
8465+
uuid: "00000000-0000-4000-8000-000000000a02",
8466+
session_id: WAKE_NATIVE_SESSION,
8467+
}),
8468+
claudeSdkFrame({
8469+
type: "user",
8470+
message: {
8471+
role: "user",
8472+
content: [{ type: "tool_result", tool_use_id: toolUseId, content: "line 1" }],
8473+
},
8474+
parent_tool_use_id: null,
8475+
uuid: "00000000-0000-4000-8000-000000000a03",
8476+
session_id: WAKE_NATIVE_SESSION,
8477+
tool_use_result: { stdout: "line 1\n", stderr: "", interrupted: false },
8478+
}),
8479+
makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a04", result: "Built." }),
8480+
]) {
8481+
yield* Queue.offer(harness.sdkMessages, frame);
8482+
}
8483+
yield* harness.awaitTerminal(1);
8484+
8485+
const chunks = harness.events.flatMap((event) =>
8486+
event.type === "command_output.delta" ? [event.chunk] : [],
8487+
);
8488+
assert.deepEqual(chunks, ["line 1\n"]);
8489+
}).pipe(
8490+
Effect.provideService(HostProcess.Platform, "linux"),
8491+
Effect.provide(
8492+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
8493+
),
8494+
),
8495+
),
8496+
);
8497+
83888498
it.effect("preserves settled background work until an explicit stop", () =>
83898499
Effect.scoped(
83908500
Effect.gen(function* () {

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,12 @@ const writeReplayTaskOutputFiles = Effect.fn("ClaudeReplay.writeTaskOutputFiles"
992992
const stderr = typeof result.stderr === "string" ? result.stderr : "";
993993
outputByToolUseId.set(toolResult.tool_use_id, `${result.stdout}${stderr}`);
994994
}
995-
const root = claudeTaskOutputRoot({ CLAUDE_CODE_TMPDIR: tmpRoot }, path);
995+
const root = claudeTaskOutputRoot(
996+
{ CLAUDE_CODE_TMPDIR: tmpRoot },
997+
path,
998+
null,
999+
yield* HostProcess.Platform,
1000+
);
9961001
for (const frame of frames) {
9971002
if (frame.type !== "system" || frame.subtype !== "task_started") continue;
9981003
if (frame.task_type !== "local_bash" || frame.is_backgrounded !== false) continue;

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4042,6 +4042,9 @@ interface ClaudeLiveQueryContext {
40424042
permissionMode: PermissionMode;
40434043
stopping: boolean;
40444044
readonly subagentsFromEarlierProcesses: ReadonlySet<ActiveClaudeSubagent>;
4045+
// Per process, not per session: a relative temp dir follows the cwd this CLI
4046+
// was launched in, and a later turn's cwd can differ.
4047+
readonly taskOutputRoot: string | undefined;
40454048
}
40464049

40474050
interface ActiveClaudeToolCall {
@@ -4470,6 +4473,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
44704473
adapterOptions.environment,
44714474
).pipe(Effect.provideService(Path.Path, path));
44724475
const hostEnvironment = yield* HostProcess.Environment;
4476+
const hostPlatform = yield* HostProcess.Platform;
44734477
const customHome =
44744478
adapterOptions.settings.homePath.trim().length > 0 ||
44754479
adapterOptions.environment.CLAUDE_CONFIG_DIR !== hostEnvironment.CLAUDE_CONFIG_DIR;
@@ -4647,7 +4651,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
46474651
string,
46484652
{ readonly fiber: Fiber.Fiber<void>; readonly stop: Deferred.Deferred<void> }
46494653
>();
4650-
const taskOutputRoot = claudeTaskOutputRoot(adapterOptions.environment, path);
46514654
// Waits for the tail's last read so its output lands before the call settles.
46524655
const stopTaskOutputTail = (toolUseId: string) =>
46534656
Effect.suspend(() => {
@@ -8785,7 +8788,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
87858788
? undefined
87868789
: findToolCall(context, message.tool_use_id);
87878790
if (
8788-
taskOutputRoot !== undefined &&
8791+
liveQuery.taskOutputRoot !== undefined &&
87898792
message.task_type === "local_bash" &&
87908793
// Older Claude Code builds omit the flag on foreground calls.
87918794
message.is_backgrounded !== true &&
@@ -8798,7 +8801,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
87988801
});
87998802
const stop = yield* Deferred.make<void>();
88008803
const fiber = yield* tailClaudeTaskOutput({
8801-
root: taskOutputRoot,
8804+
root: liveQuery.taskOutputRoot,
88028805
sessionId: message.session_id,
88038806
taskId: message.task_id,
88048807
stop,
@@ -10199,6 +10202,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
1019910202
...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }),
1020010203
...(toolSearchOverride === undefined ? {} : { toolSearchOverride }),
1020110204
});
10205+
const resolvedTurnCwd = yield* fileSystem
10206+
.realPath(turnInput.runtimePolicy.cwd ?? ".")
10207+
.pipe(Effect.orElseSucceed(() => turnInput.runtimePolicy.cwd));
1020210208
const querySession = yield* queryRunner
1020310209
.open({
1020410210
threadId: turnInput.threadId,
@@ -10248,6 +10254,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
1024810254
(subagent) => subagent.task.status === "running",
1024910255
),
1025010256
),
10257+
taskOutputRoot: claudeTaskOutputRoot(
10258+
adapterOptions.environment,
10259+
path,
10260+
resolvedTurnCwd,
10261+
hostPlatform,
10262+
),
1025110263
};
1025210264
yield* Ref.set(queryContext, context);
1025310265
yield* querySession.messages.pipe(

‎apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as FileSystem from "effect/FileSystem";
77
import * as Path from "effect/Path";
88
import * as Queue from "effect/Queue";
99

10-
import { tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts";
10+
import { claudeTaskOutputRoot, tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts";
1111

1212
const SESSION_ID = "0c91bb28-9e89-4889-bd22-3de9880233d3";
1313
const TASK_ID = "bk6xreeye";
@@ -103,3 +103,39 @@ describe("tailClaudeTaskOutput", () => {
103103
),
104104
);
105105
});
106+
107+
describe("claudeTaskOutputRoot", () => {
108+
it.effect.skipIf(process.getuid === undefined)(
109+
"uses the temp dir Claude Code resolves from its environment on each platform",
110+
() =>
111+
Effect.gen(function* () {
112+
const path = yield* Path.Path;
113+
const user = `claude-${process.getuid?.()}`;
114+
const root = (
115+
environment: Record<string, string>,
116+
platform: NodeJS.Platform,
117+
cwd: string | null = null,
118+
) => claudeTaskOutputRoot(environment, path, cwd, platform);
119+
assert.equal(root({ TMPDIR: "/var/folders/x/T/" }, "linux"), `/var/folders/x/T/${user}`);
120+
assert.equal(root({ TMP: "/tmp/user/1000" }, "linux"), `/tmp/user/1000/${user}`);
121+
assert.equal(root({}, "linux"), `/tmp/${user}`);
122+
assert.equal(root({ TMPDIR: "/var/folders/x/T/" }, "darwin"), `/tmp/${user}`);
123+
assert.equal(
124+
root({ CLAUDE_CODE_TMPDIR: "/srv/claude", TMPDIR: "/var/folders/x/T/" }, "linux"),
125+
`/srv/claude/${user}`,
126+
);
127+
assert.equal(
128+
root({ CLAUDE_CODE_TMPDIR: "/srv/claude", TMPDIR: "/var/folders/x/T/" }, "darwin"),
129+
`/srv/claude/${user}`,
130+
);
131+
assert.equal(
132+
root({ TMPDIR: "tmp" }, "linux", "/work/project"),
133+
`/work/project/tmp/${user}`,
134+
);
135+
assert.equal(
136+
root({ CLAUDE_CODE_TMPDIR: "../claude" }, "darwin", "/work/project"),
137+
`/work/claude/${user}`,
138+
);
139+
}).pipe(Effect.provide(NodeServices.layer)),
140+
);
141+
});

‎apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,21 @@ const MAX_READ_BYTES = 256 * 1024;
1717
export function claudeTaskOutputRoot(
1818
environment: Readonly<Record<string, string | undefined>> | undefined,
1919
path: Path.Path,
20+
cwd: string | null,
21+
platform: NodeJS.Platform,
2022
): string | undefined {
2123
const uid = process.getuid?.();
2224
if (uid === undefined) return undefined;
23-
const base = environment?.CLAUDE_CODE_TMPDIR?.trim() || "/tmp";
24-
return path.join(base, `claude-${uid}`);
25+
// Claude Code's macOS build falls back to /tmp and ignores the TMPDIR that
26+
// launchd always sets. Its other builds use `os.tmpdir()`, which reads these
27+
// from the environment it was launched with. A relative value is relative
28+
// to Claude's working directory, not this server's.
29+
const fallback =
30+
platform === "darwin"
31+
? "/tmp"
32+
: environment?.TMPDIR || environment?.TMP || environment?.TEMP || "/tmp";
33+
const base = environment?.CLAUDE_CODE_TMPDIR?.trim() || fallback;
34+
return path.resolve(cwd ?? ".", base, `claude-${uid}`);
2535
}
2636

2737
const locate = Effect.fn("ClaudeTaskOutputTail.locate")(function* (input: {

0 commit comments

Comments
 (0)