Open History to see the last commit
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import { ChildProcess, ChildProcessSpawner } from "effect/process";
7
8import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts";
9import { resolveSpawnCommand } from "@t3tools/shared/shell";
10
11import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
12import {
13 normalizeCliError,
14 toJsonSchemaObject,
15} from "@t3tools/provider-core/server/textGenerationUtils";
16import {
17 getModelSelectionBooleanOptionValue,
18 getModelSelectionStringOptionValue,
19 getProviderOptionDescriptors,
20} from "@t3tools/shared/model";
21import {
22 BUNDLED_CLAUDE_MODEL_CATALOG,
23 type ClaudeModelCatalog,
24 getClaudeCatalogModelCapabilities,
25 normalizeClaudeCatalogEffort,
26 resolveClaudeCatalogApiModelId,
27 resolveClaudeCatalogEffort,
28 resolveClaudeModelSlug,
29 scopeClaudeModelCatalog,
30} from "../provider/ClaudeModelCatalog.ts";
31import {
32 hasClaudeManagedMcpConfig,
33 makeClaudeEnvironment,
34} from "../provider/Drivers/ClaudeHome.ts";
35
36const CLAUDE_TIMEOUT_MS = 180_000;
37/** `--tools` takes bare names; patterns go in the allow list. */
38const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash";
39/** In dontAsk mode every call outside this list, Bash included, is refused. */
40const CLAUDE_INVESTIGATION_ALLOWED = [
41 "Read",
42 "Grep",
43 "Glob",
44 "Bash(git diff:*)",
45 "Bash(git log:*)",
46 "Bash(git show:*)",
47].join(",");
48// Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere.
49const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(",");
50
51const NO_SESSION_PERSISTENCE = "--no-session-persistence";
52// Claude Code before 2.0.63 exits with this on stderr; those runs retry without the flag.
53const NO_SESSION_PERSISTENCE_REJECTED = `error: unknown option '${NO_SESSION_PERSISTENCE}'`;
54
55const ClaudeOutputEnvelope = Schema.Struct({
56 structured_output: Schema.Unknown,
57});
58const ClaudeOutputMessage = Schema.Struct({
59 type: Schema.String,
60 structured_output: Schema.optionalKey(Schema.Unknown),
61});
62const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope);
63
64const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
65const decodeClaudeOutput = Schema.decodeEffect(
66 Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])),
67);
68
69// Only `success` results carry `result`; error subtypes such as
70// error_max_structured_output_retries report through `errors` instead.
71const ClaudeErrorResult = Schema.Struct({
72 is_error: Schema.Literal(true),
73 result: Schema.optionalKey(Schema.String),
74 errors: Schema.optionalKey(Schema.Array(Schema.String)),
75});
76const isClaudeErrorResult = Schema.is(ClaudeErrorResult);
77const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
78
79// Claude reports JSON errors on stdout; stderr may contain only wrapper notices.
80function claudeErrorResult(stdout: string): string | undefined {
81 const output = Option.getOrUndefined(decodeJsonOption(stdout));
82 const messages: ReadonlyArray<unknown> = Array.isArray(output) ? output : [output];
83 return messages
84 .filter(isClaudeErrorResult)
85 .map((message) =>
86 (
87 message.result ??
88 // error_during_execution mixes CLI telemetry into the list.
89 message.errors?.filter((error) => !error.startsWith("[ede_diagnostic]")).join("\n") ??
90 ""
91 ).trim(),
92 )
93 .findLast((result) => result.length > 0);
94}
95
96export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
97 claudeSettings: ClaudeSettings,
98 environment?: NodeJS.ProcessEnv,
99 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),
100) {
101 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
102 const fileSystem = yield* FileSystem.FileSystem;
103 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
104 const scopedModelCatalog = modelCatalog.pipe(
105 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),
106 );
107
108 const readStreamAsString = <E>(
109 operation: string,
110 stream: Stream.Stream<Uint8Array, E>,
111 ): Effect.Effect<string, TextGenerationError> =>
112 stream.pipe(
113 Stream.decodeText(),
114 Stream.runFold(
115 () => "",
116 (acc, chunk) => acc + chunk,
117 ),
118 Effect.mapError((cause) =>
119 normalizeCliError("claude", operation, cause, "Failed to collect process output"),
120 ),
121 );
122
123 const encodeJsonForOperation = (
124 operation: TextGenerationOperations.Operation,
125 value: unknown,
126 detail: string,
127 ): Effect.Effect<string, TextGenerationError> =>
128 encodeJsonString(value).pipe(
129 Effect.mapError(
130 (cause) =>
131 new TextGenerationError({
132 operation,
133 detail,
134 cause,
135 }),
136 ),
137 );
138
139 const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({
140 operation,
141 cwd,
142 prompt,
143 outputSchema: outputSchemaJson,
144 modelSelection,
145 investigate,
146 }: TextGenerationOperations.Request<S>): Effect.fn.Return<
147 S["Type"],
148 TextGenerationError,
149 S["DecodingServices"]
150 > {
151 const catalog = yield* scopedModelCatalog;
152 const resolvedModelSelection = {
153 ...modelSelection,
154 model: resolveClaudeModelSlug(catalog, modelSelection.model),
155 };
156 const jsonSchemaStr = yield* encodeJsonForOperation(
157 operation,
158 toJsonSchemaObject(outputSchemaJson),
159 "Failed to encode structured output schema.",
160 );
161 const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model);
162 const descriptors = getProviderOptionDescriptors({
163 caps,
164 selections: resolvedModelSelection.options,
165 });
166 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
167 const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort");
168 const resolvedEffort = resolveClaudeCatalogEffort(
169 catalog,
170 resolvedModelSelection.model,
171 rawEffortSelection,
172 );
173 const cliEffort = normalizeClaudeCatalogEffort(
174 catalog,
175 resolvedEffort,
176 resolvedModelSelection.model,
177 );
178 const ultracode = findDescriptor("ultracode")?.currentValue;
179 const thinkingDescriptor = findDescriptor("thinking");
180 const fastModeDescriptor = findDescriptor("fastMode");
181 // Only a chosen value; otherwise Claude Code's own default applies, as in chat turns.
182 const thinking =
183 thinkingDescriptor?.type === "boolean"
184 ? getModelSelectionBooleanOptionValue(resolvedModelSelection, "thinking")
185 : undefined;
186 const fastMode =
187 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
188 const settings = {
189 disableAllHooks: true,
190 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
191 ...(fastMode ? { fastMode: true } : {}),
192 ...(typeof ultracode === "boolean" ? { ultracode } : {}),
193 };
194 const settingsJson = yield* encodeJsonForOperation(
195 operation,
196 settings,
197 "Failed to encode Claude CLI settings.",
198 );
199
200 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* (
201 noSessionPersistence: boolean,
202 ) {
203 const workingDirectory =
204 operation === "generateThreadTitle"
205 ? yield* fileSystem
206 .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" })
207 .pipe(
208 Effect.mapError((cause) =>
209 normalizeCliError("claude", operation, cause, "Failed to create title directory"),
210 ),
211 )
212 : cwd;
213 const managedMcpConfig = yield* hasClaudeManagedMcpConfig();
214 const spawnCommand = yield* resolveSpawnCommand(
215 claudeSettings.binaryPath || "claude",
216 [
217 "-p",
218 "--output-format",
219 "json",
220 "--json-schema",
221 jsonSchemaStr,
222 "--model",
223 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),
224 ...(cliEffort ? ["--effort", cliEffort] : []),
225 "--settings",
226 settingsJson,
227 ...(investigate
228 ? [
229 "--tools",
230 CLAUDE_INVESTIGATION_TOOLS,
231 "--allowedTools",
232 CLAUDE_INVESTIGATION_ALLOWED,
233 "--disallowedTools",
234 CLAUDE_INVESTIGATION_DENIED,
235 ]
236 : ["--tools", ""]),
237 "--disable-slash-commands",
238 ...(noSessionPersistence ? [NO_SESSION_PERSISTENCE] : []),
239 // managed-mcp.json already isolates servers and makes Claude reject this flag.
240 ...(managedMcpConfig ? [] : ["--strict-mcp-config"]),
241 "--permission-mode",
242 "dontAsk",
243 ],
244 { env: claudeEnvironment },
245 );
246 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
247 env: claudeEnvironment,
248 cwd: workingDirectory,
249 shell: spawnCommand.shell,
250 stdin: {
251 stream: Stream.encodeText(Stream.make(prompt)),
252 },
253 forceKillAfter: "1 second",
254 });
255
256 const child = yield* commandSpawner
257 .spawn(command)
258 .pipe(
259 Effect.mapError((cause) =>
260 normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
261 ),
262 );
263
264 const [stdout, stderr, exitCode] = yield* Effect.all(
265 [
266 readStreamAsString(operation, child.stdout),
267 readStreamAsString(operation, child.stderr),
268 child.exitCode.pipe(
269 Effect.mapError((cause) =>
270 normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"),
271 ),
272 ),
273 ],
274 { concurrency: "unbounded" },
275 );
276
277 return { stdout, stderr, exitCode };
278 });
279
280 const rawStdout = yield* runClaudeCommand(true).pipe(
281 Effect.filterOrElse(
282 (result) =>
283 result.exitCode === 0 || !result.stderr.includes(NO_SESSION_PERSISTENCE_REJECTED),
284 () => runClaudeCommand(false),
285 ),
286 Effect.flatMap(({ stdout, stderr, exitCode }) => {
287 if (exitCode === 0) return Effect.succeed(stdout);
288 const stderrDetail = stderr.trim();
289 const stdoutDetail = stdout.trim();
290 const detail =
291 claudeErrorResult(stdoutDetail) ??
292 (stderrDetail.length > 0 ? stderrDetail : stdoutDetail);
293 return Effect.fail(
294 new TextGenerationError({
295 operation,
296 detail:
297 detail.length > 0
298 ? `Claude CLI command failed: ${detail}`
299 : `Claude CLI command failed with code ${exitCode}.`,
300 }),
301 );
302 }),
303 Effect.scoped,
304 Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS),
305 Effect.flatMap(
306 Option.match({
307 onNone: () =>
308 Effect.fail(
309 new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
310 ),
311 onSome: (value) => Effect.succeed(value),
312 }),
313 ),
314 );
315
316 const output = yield* decodeClaudeOutput(rawStdout).pipe(
317 Effect.catchTags({
318 SchemaError: (cause) =>
319 Effect.fail(
320 new TextGenerationError({
321 operation,
322 detail: "Claude CLI returned unexpected output format.",
323 cause,
324 }),
325 ),
326 }),
327 );
328 const envelope = isClaudeOutputEnvelope(output)
329 ? output
330 : output.findLast((message) => message.type === "result");
331
332 const decodeOutput = Schema.decodeEffect(outputSchemaJson);
333 return yield* decodeOutput(envelope?.structured_output).pipe(
334 Effect.catchTags({
335 SchemaError: (cause) =>
336 Effect.fail(
337 new TextGenerationError({
338 operation,
339 detail: "Claude returned invalid structured output.",
340 cause,
341 }),
342 ),
343 }),
344 );
345 });
346
347 return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson);
348});