Skip to content
Source
Sign inSign up

ClaudeTaskOutputTail.ts

126 lines4.7 KB

1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Path from "effect/Path";
6​
7/** The SDK withholds stdout until tool results; tail task_started files for live output.
8 * The private Claude layout can change; failures omit live output and retain the final result. */
9​
10const CLAUDE_TASK_OUTPUT_POLL_MS = 250;
11/** Bound discovery when Claude never writes the file. */
12const MAX_LOCATE_ATTEMPTS = 40;
13/** Skip ahead of fast writers; only the tail is displayed. */
14const MAX_READ_BYTES = 256 * 1024;
15​
16/** Claude Code's per-user temp root, or none where it has no uid (Windows). */
17export function claudeTaskOutputRoot(
18 environment: Readonly<Record<string, string | undefined>> | undefined,
19 path: Path.Path,
20 cwd: string | null,
21 platform: NodeJS.Platform,
22): string | undefined {
23 const uid = process.getuid?.();
24 if (uid === undefined) return undefined;
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}`);
36​
37const locate = Effect.fn("ClaudeTaskOutputTail.locate")(function* (input: {
38 readonly root: string;
39 readonly sessionId: string;
40 readonly taskId: string;
41}) {
42 const fileSystem = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const projects = yield* fileSystem.readDirectory(input.root).pipe(Effect.orElseSucceed(() => []));
45 for (const project of projects) {
46 const candidate = path.join(
47 input.root,
48 project,
49 input.sessionId,
50 "tasks",
51 `${input.taskId}.output`,
52 );
53 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
54 return Option.some(candidate);
55 }
56 }
57 return Option.none<string>();
58});
59​
60/** Complete stop on tool result or turn end, then await the effect; it performs a final read before returning. */
61export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(function* (input: {
62 readonly root: string;
63 readonly sessionId: string;
64 readonly taskId: string;
65 readonly stop: Deferred.Deferred<void>;
66 readonly onChunk: (chunk: string) => Effect.Effect<void>;
67}) {
68 const fileSystem = yield* FileSystem.FileSystem;
69 const pause = Deferred.await(input.stop).pipe(
70 Effect.as(true),
71 Effect.timeoutOrElse({
72 duration: CLAUDE_TASK_OUTPUT_POLL_MS,
73 orElse: () => Effect.succeed(false),
74 }),
75 );
76 let file = Option.none<string>();
77 for (let attempt = 0; Option.isNone(file); attempt += 1) {
78 file = yield* locate(input);
79 if (Option.isSome(file)) break;
80 if (attempt + 1 >= MAX_LOCATE_ATTEMPTS || (yield* pause)) return;
81 }
82 const filePath = file.value;
83 let decoder = new TextDecoder("utf-8", { fatal: false });
84 let offset = 0;
85 let last = yield* Deferred.isDone(input.stop);
86 while (true) {
87 const size = yield* fileSystem.stat(filePath).pipe(
88 Effect.map((info) => Number(info.size)),
89 Effect.orElseSucceed(() => offset),
90 );
91 if (size < offset) {
92 offset = 0;
93 decoder = new TextDecoder("utf-8", { fatal: false });
94 }
95 if (size > offset) {
96 const start = size - offset > MAX_READ_BYTES ? size - MAX_READ_BYTES : offset;
97 const bytes = yield* Effect.scoped(
98 Effect.gen(function* () {
99 const handle = yield* fileSystem.open(filePath, { flag: "r" });
100 yield* handle.seek(BigInt(start), "start");
101 return yield* handle.readAlloc(size - start);
102 }),
103 ).pipe(Effect.orElseSucceed(() => Option.none<Uint8Array>()));
104 if (Option.isSome(bytes)) {
105 // Skipped output must not splice two unrelated lines together.
106 const skipped = start > offset;
107 let first = 0;
108 if (skipped) {
109 // Reset partial UTF-8 bytes, and skip the rest of a code point the cut landed inside.
110 decoder = new TextDecoder("utf-8", { fatal: false });
111 while (((bytes.value[first] ?? 0) & 0xc0) === 0x80) first += 1;
112 }
113 offset = start + bytes.value.byteLength;
114 const text =
115 (skipped ? "\n" : "") + decoder.decode(bytes.value.subarray(first), { stream: true });
116 if (text.length > 0) yield* input.onChunk(text);
117 }
118 }
119 if (last) {
120 const rest = decoder.decode();
121 if (rest.length > 0) yield* input.onChunk(rest);
122 return;
123 }
124 last = yield* pause;
125 }
126});