1import { SERVER_VERSION } from "../version.ts";
2/**
3 * `t3 triage` - hand a misbehaving install to the user's own coding agent.
4 *
5 * The command is deliberately thin: it writes a `context.md` with machine facts
6 * (versions, paths, server liveness), then launches claude or codex
7 * interactively, seeded with the playbook from `triagePrompt.ts`. The agent
8 * asks the user what went wrong, investigates, and files the issue; the
9 * harness's own permission prompts gate anything it wants to run. With no
10 * agent CLI installed, the prompt and context are written to disk for the user
11 * to paste into whatever agent they do have.
12 */
13// @effect-diagnostics nodeBuiltinImport:off
14import * as NodeChildProcess from "node:child_process";
15import * as NodeOS from "node:os";
16import * as NodeReadlinePromises from "node:readline/promises";
17
18import * as HostProcess from "@t3tools/shared/HostProcess";
19import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
20import * as Config from "effect/Config";
21import * as Console from "effect/Console";
22import * as DateTime from "effect/DateTime";
23import * as Effect from "effect/Effect";
24import * as FileSystem from "effect/FileSystem";
25import * as Option from "effect/Option";
26import * as Path from "effect/Path";
27import * as Schema from "effect/Schema";
28import { Command, Flag } from "effect/cli";
29
30import * as BootService from "../cloud/bootService.ts";
31import * as ServerConfig from "../config.ts";
32import { resolveBaseDir } from "../os-jank.ts";
33import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
34import { baseDirFlag } from "./config.ts";
35import { resolveCliCommand } from "./invocation.ts";
36import {
37 buildTriageContext,
38 buildTriageLaunchPrompt,
39 buildTriageSeedPrompt,
40} from "./triagePrompt.ts";
41
42interface TriageAgent {
43 readonly id: "claude" | "codex";
44 readonly command: string;
45 readonly label: string;
46}
47
48const TRIAGE_AGENTS: ReadonlyArray<TriageAgent> = [
49 { id: "claude", command: "claude", label: "Claude Code" },
50 { id: "codex", command: "codex", label: "Codex" },
51];
52
53export class TriageAgentUnavailableError extends Schema.TaggedError<TriageAgentUnavailableError>()(
54 "TriageAgentUnavailableError",
55 { agent: Schema.String },
56) {
57 override get message(): string {
58 return `\`${this.agent}\` is not installed or was not found on PATH.`;
59 }
60}
61
62export class TriageAgentChoiceRequiredError extends Schema.TaggedError<TriageAgentChoiceRequiredError>()(
63 "TriageAgentChoiceRequiredError",
64 {},
65) {
66 override get message(): string {
67 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.";
68 }
69}
70
71export class TriageAgentSpawnError extends Schema.TaggedError<TriageAgentSpawnError>()(
72 "TriageAgentSpawnError",
73 { command: Schema.String, cause: Schema.Defect() },
74) {
75 override get message(): string {
76 return `Could not start \`${this.command}\`.`;
77 }
78}
79
80/**
81 * One human-readable line about the local server, for `context.md`. Includes
82 * the version the server recorded, which can differ from this CLI's version.
83 */
84export const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* (
85 serverRuntimeStatePath: string,
86) {
87 // readPersistedServerRuntimeState swallows read/decode failures itself and
88 // returns none, so a corrupt state file reads as "not running" here.
89 const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath);
90 if (Option.isNone(state)) {
91 return "not running (no server-runtime.json; the server may never have started here)";
92 }
93 const version =
94 state.value.serverVersion === undefined
95 ? "version not recorded"
96 : `version ${state.value.serverVersion}`;
97 if (!isProcessAlive(state.value.pid)) {
98 return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin}, ${version})`;
99 }
100 return `running (pid ${String(state.value.pid)}, ${state.value.origin}, ${version})`;
101});
102
103const pickAgent = (agents: ReadonlyArray<TriageAgent>) =>
104 Effect.promise(async () => {
105 const readline = NodeReadlinePromises.createInterface({
106 input: process.stdin,
107 output: process.stdout,
108 });
109 try {
110 const menu = agents
111 .map((agent, index) => ` [${String(index + 1)}] ${agent.label}`)
112 .join("\n");
113 for (;;) {
114 const answer = (await readline.question(`Run triage with:\n${menu}\n> `)).trim();
115 const byNumber = agents[Number.parseInt(answer, 10) - 1];
116 if (byNumber !== undefined) {
117 return byNumber;
118 }
119 const byId = agents.find((agent) => agent.id === answer.toLowerCase());
120 if (byId !== undefined) {
121 return byId;
122 }
123 }
124 } finally {
125 readline.close();
126 }
127 });
128
129/**
130 * Run the agent CLI as a normal interactive session: the user's terminal is
131 * the UI, and the harness's own permission prompts gate every action. Resolves
132 * with the child's exit code.
133 */
134const runInteractiveSession = (input: {
135 readonly command: string;
136 readonly args: ReadonlyArray<string>;
137 readonly shell: boolean;
138 readonly cwd: string;
139}) =>
140 Effect.callback<number, TriageAgentSpawnError>((resume) => {
141 const child = NodeChildProcess.spawn(input.command, [...input.args], {
142 cwd: input.cwd,
143 stdio: "inherit",
144 shell: input.shell,
145 });
146 child.once("error", (cause) =>
147 resume(Effect.fail(new TriageAgentSpawnError({ command: input.command, cause }))),
148 );
149 // Signal death has no exit code; report failure rather than success.
150 child.once("exit", (code, signal) => resume(Effect.succeed(code ?? (signal === null ? 0 : 1))));
151 });
152
153const agentFlag = Flag.Literals("agent", ["claude", "codex"]).pipe(
154 Flag.withDescription("Agent CLI to use. Default: ask when both are installed."),
155 Flag.optional,
156);
157
158const modelFlag = Flag.String("model").pipe(
159 Flag.withDescription("Model passed through to the agent CLI. Default: the agent's default."),
160 Flag.optional,
161);
162
163export const triageCommand = Command.make("triage", {
164 baseDir: baseDirFlag,
165 agent: agentFlag,
166 model: modelFlag,
167}).pipe(
168 Command.withDescription(
169 "Investigate a T3 Code problem on this machine with claude or codex, and help file a good issue.",
170 ),
171 Command.withHandler((flags) =>
172 Effect.gen(function* () {
173 const fs = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175
176 // Triage is a user-facing feature: always the userdata state, never dev.
177 // --base-dir wins; T3CODE_HOME is its documented env equivalent (same
178 // precedence as `t3 pair`).
179 const explicitBaseDir = Option.getOrUndefined(flags.baseDir);
180 const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option);
181 const baseDir = yield* resolveBaseDir(explicitBaseDir ?? Option.getOrUndefined(envHome));
182 const paths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {});
183
184 const now = yield* DateTime.now;
185 const scratchDir = path.join(
186 paths.stateDir,
187 "triage",
188 // ISO instant, made safe for Windows paths.
189 DateTime.formatIso(now).replaceAll(":", "-").replace(".", "-"),
190 );
191 yield* fs.makeDirectory(scratchDir, { recursive: true });
192
193 const contextFilePath = path.join(scratchDir, "context.md");
194 yield* fs.writeFileString(
195 contextFilePath,
196 buildTriageContext({
197 generatedAt: DateTime.formatIso(now),
198 cliVersion: SERVER_VERSION,
199 os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`,
200 nodeVersion: process.version,
201 launchedAs: yield* resolveCliCommand("triage"),
202 server: yield* describeServerProcess(paths.serverRuntimeStatePath),
203 paths: {
204 stateDir: paths.stateDir,
205 dbPath: paths.dbPath,
206 settingsPath: paths.settingsPath,
207 logsDir: paths.logsDir,
208 // The server writes no log file of its own. Service installs and the
209 // desktop app capture its output. The glob covers every desktop backend
210 // (such as WSL) and rotated copies; names come from DesktopObservability.ts.
211 serviceLogPath: path.join(paths.logsDir, BootService.BOOT_SERVICE_LOG_FILE),
212 desktopBackendLogGlob: path.join(paths.logsDir, "server-child*.log*"),
213 serverTracePath: paths.serverTracePath,
214 providerEventLogPath: paths.providerEventLogPath,
215 terminalLogsDir: paths.terminalLogsDir,
216 providerStatusCacheDir: paths.providerStatusCacheDir,
217 secretsDir: paths.secretsDir,
218 sourceCacheDir: path.join(baseDir, "source"),
219 },
220 }),
221 );
222
223 const installed: Array<TriageAgent> = [];
224 for (const agent of TRIAGE_AGENTS) {
225 if (yield* isCommandAvailable(agent.command)) {
226 installed.push(agent);
227 }
228 }
229
230 const requested = Option.getOrUndefined(flags.agent);
231 let selected: TriageAgent | undefined;
232 if (requested !== undefined) {
233 selected = installed.find((agent) => agent.id === requested);
234 if (selected === undefined) {
235 return yield* new TriageAgentUnavailableError({ agent: requested });
236 }
237 } else if (installed.length === 1) {
238 selected = installed[0];
239 } else if (installed.length > 1) {
240 // Both streams must be terminals: with stdout redirected the picker
241 // prompt is invisible and the command would hang waiting on it.
242 if (!process.stdin.isTTY || !process.stdout.isTTY) {
243 return yield* new TriageAgentChoiceRequiredError();
244 }
245 selected = yield* pickAgent(installed);
246 }
247
248 // The full seed prompt always goes to disk. The agent is launched with a
249 // one-line pointer at it: Windows `.cmd` shims run through cmd.exe,
250 // which cannot carry the multiline playbook as an argv string, and with
251 // no agent installed the same file is the paste-anywhere fallback.
252 const promptFilePath = path.join(scratchDir, "prompt.md");
253 yield* fs.writeFileString(promptFilePath, buildTriageSeedPrompt(contextFilePath));
254
255 if (selected === undefined) {
256 yield* Console.log(
257 [
258 "No supported agent CLI (claude, codex) was found on this machine.",
259 "",
260 "The triage prompt and machine context were written to:",
261 ` ${promptFilePath}`,
262 ` ${contextFilePath}`,
263 "",
264 "Paste the prompt file into any coding agent to run triage by hand.",
265 ].join("\n"),
266 );
267 return;
268 }
269
270 const model = Option.getOrUndefined(flags.model);
271 const spawnSpec = yield* resolveSpawnCommand(selected.command, [
272 ...(model === undefined ? [] : ["--model", model]),
273 buildTriageLaunchPrompt(promptFilePath),
274 ]);
275 yield* Console.log(`Starting ${selected.label}. It will ask what went wrong.\n`);
276 const exitCode = yield* runInteractiveSession({ ...spawnSpec, cwd: scratchDir });
277 if (exitCode !== 0) {
278 process.exitCode = exitCode;
279 }
280 }),
281 ),
282);