Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeReadlinePromises from "node:readline/promises";
6
7import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
8import * as Config from "effect/Config";
9import * as Console from "effect/Console";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
13import { Command, Flag } from "effect/cli";
14
15import packageJson from "../../package.json" with { type: "json" };
16import * as ServerConfig from "../config.ts";
17import { resolveBaseDir } from "../os-jank.ts";
18import { buildTriageLaunchPrompt } from "../triage/triagePrompt.ts";
19import { describeServerProcess, writeTriageSession } from "../triage/triageSession.ts";
20import { baseDirFlag } from "./config.ts";
21import { interactiveStdin } from "./consoleStdin.ts";
22import { resolveCliCommand } from "./invocation.ts";
23
24interface TriageAgent {
25 readonly id: "claude" | "codex";
26 readonly command: string;
27 readonly label: string;
28 readonly stateDirEnv: string;
29 readonly defaultStateDir: string;
30}
31
32const TRIAGE_AGENTS: ReadonlyArray<TriageAgent> = [
33 {
34 id: "claude",
35 command: "claude",
36 label: "Claude Code",
37 stateDirEnv: "CLAUDE_CONFIG_DIR",
38 defaultStateDir: ".claude",
39 },
40 {
41 id: "codex",
42 command: "codex",
43 label: "Codex",
44 stateDirEnv: "CODEX_HOME",
45 defaultStateDir: ".codex",
46 },
47];
48
49const resolveAgentStateDir = (agent: TriageAgent) =>
50 process.env[agent.stateDirEnv]?.trim() || NodePath.join(NodeOS.homedir(), agent.defaultStateDir);
51
52export class TriageAgentUnavailableError extends Schema.TaggedError<TriageAgentUnavailableError>()(
53 "TriageAgentUnavailableError",
54 { agent: Schema.String },
55) {
56 override get message(): string {
57 return `\`${this.agent}\` is not installed or was not found on PATH.`;
58 }
59}
60
61export class TriageAgentChoiceRequiredError extends Schema.TaggedError<TriageAgentChoiceRequiredError>()(
62 "TriageAgentChoiceRequiredError",
63 {},
64) {
65 override get message(): string {
66 return "Both claude and codex are installed and there is no terminal to ask which to use. Re-run with --agent claude or --agent codex.";
67 }
68}
69
70export class TriageAgentSpawnError extends Schema.TaggedError<TriageAgentSpawnError>()(
71 "TriageAgentSpawnError",
72 { command: Schema.String, cause: Schema.Defect() },
73) {
74 override get message(): string {
75 return `Could not start \`${this.command}\`.`;
76 }
77}
78
79const pickAgent = (agents: ReadonlyArray<TriageAgent>) =>
80 Effect.promise(async () => {
81 const readline = NodeReadlinePromises.createInterface({
82 input: process.stdin,
83 output: process.stdout,
84 });
85 try {
86 const menu = agents
87 .map((agent, index) => ` [${String(index + 1)}] ${agent.label}`)
88 .join("\n");
89 for (;;) {
90 const answer = (await readline.question(`Run triage with:\n${menu}\n> `)).trim();
91 const byNumber = agents[Number.parseInt(answer, 10) - 1];
92 if (byNumber !== undefined) {
93 return byNumber;
94 }
95 const byId = agents.find((agent) => agent.id === answer.toLowerCase());
96 if (byId !== undefined) {
97 return byId;
98 }
99 }
100 } finally {
101 readline.close();
102 }
103 });
104
105const runInteractiveSession = (input: {
106 readonly command: string;
107 readonly args: ReadonlyArray<string>;
108 readonly shell: boolean;
109 readonly cwd: string;
110}) =>
111 Effect.callback<number, TriageAgentSpawnError>((resume) => {
112 const child = NodeChildProcess.spawn(input.command, [...input.args], {
113 cwd: input.cwd,
114 // The Windows desktop launcher supplies terminal input through the reopened console.
115 stdio: [interactiveStdin(), "inherit", "inherit"],
116 shell: input.shell,
117 });
118 child.once("error", (cause) =>
119 resume(Effect.fail(new TriageAgentSpawnError({ command: input.command, cause }))),
120 );
121 child.once("exit", (code, signal) => resume(Effect.succeed(code ?? (signal === null ? 0 : 1))));
122 });
123
124const describeTriageFiles = (session: {
125 readonly promptFilePath: string;
126 readonly contextFilePath: string;
127}) => [
128 "The triage prompt and machine context were written to:",
129 ` ${session.promptFilePath}`,
130 ` ${session.contextFilePath}`,
131];
132
133const agentFlag = Flag.Literals("agent", ["claude", "codex"]).pipe(
134 Flag.withDescription("Agent CLI to use. Default: ask when both are installed."),
135 Flag.optional,
136);
137
138const modelFlag = Flag.String("model").pipe(
139 Flag.withDescription("Model passed through to the agent CLI. Default: the agent's default."),
140 Flag.optional,
141);
142
143export const triageCommand = Command.make("triage", {
144 baseDir: baseDirFlag,
145 agent: agentFlag,
146 model: modelFlag,
147}).pipe(
148 Command.withDescription(
149 "Investigate a T3 Code problem on this machine with claude or codex, and help file a good issue.",
150 ),
151 Command.withHandler((flags) =>
152 Effect.gen(function* () {
153 // Triage is a user-facing feature: always the userdata state, never dev.
154 const explicitBaseDir = Option.getOrUndefined(flags.baseDir);
155 const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option);
156 const baseDir = yield* resolveBaseDir(explicitBaseDir ?? Option.getOrUndefined(envHome));
157 const paths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {});
158
159 const session = yield* writeTriageSession({
160 baseDir,
161 paths,
162 entry: {
163 kind: "cli",
164 cliVersion: packageJson.version,
165 launchedAs: yield* resolveCliCommand("triage"),
166 },
167 server: yield* describeServerProcess(paths.serverRuntimeStatePath),
168 });
169
170 const installed: Array<TriageAgent> = [];
171 for (const agent of TRIAGE_AGENTS) {
172 if (yield* isCommandAvailable(agent.command)) {
173 installed.push(agent);
174 }
175 }
176
177 const requested = Option.getOrUndefined(flags.agent);
178 let selected: TriageAgent | undefined;
179 if (requested !== undefined) {
180 selected = installed.find((agent) => agent.id === requested);
181 if (selected === undefined) {
182 return yield* new TriageAgentUnavailableError({ agent: requested });
183 }
184 } else if (installed.length === 1) {
185 selected = installed[0];
186 } else if (installed.length > 1) {
187 // The picker needs terminal output too, or its invisible prompt hangs.
188 if (!process.stdin.isTTY || !process.stdout.isTTY) {
189 return yield* new TriageAgentChoiceRequiredError();
190 }
191 selected = yield* pickAgent(installed);
192 }
193
194 // Windows .cmd shims cannot carry multiline argv; the prompt file also serves as the paste fallback.
195 const { sessionDir, promptFilePath } = session;
196
197 if (selected === undefined) {
198 yield* Console.log(
199 [
200 "No supported agent CLI (claude, codex) was found on this machine.",
201 "",
202 ...describeTriageFiles(session),
203 "",
204 "Paste the prompt file into any coding agent to run triage by hand.",
205 ].join("\n"),
206 );
207 return;
208 }
209
210 const model = Option.getOrUndefined(flags.model);
211 const launchPrompt = buildTriageLaunchPrompt(promptFilePath);
212 const spawnSpec = yield* resolveSpawnCommand(selected.command, [
213 ...(model === undefined ? [] : ["--model", model]),
214 launchPrompt,
215 ]);
216 yield* Console.log(`Starting ${selected.label}. It will ask what went wrong.\n`);
217 const exitCode = yield* runInteractiveSession({ ...spawnSpec, cwd: sessionDir });
218 if (exitCode !== 0) {
219 process.exitCode = exitCode;
220 yield* Console.log(
221 [
222 "",
223 `${selected.label} exited with code ${String(exitCode)}.`,
224 "",
225 `${selected.label} keeps its state in ${resolveAgentStateDir(selected)}.`,
226 `If that drive is out of space, free some up or set ${selected.stateDirEnv} to a folder on another drive.`,
227 "",
228 ...describeTriageFiles(session),
229 "",
230 `To retry, start \`${selected.command}\` yourself with any flags it suggested, or use any coding agent, and give it this prompt:`,
231 launchPrompt,
232 ].join("\n"),
233 );
234 }
235 }),
236 ),
237);