| 1 | import { 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 |
| 14 | import * as NodeChildProcess from "node:child_process"; |
| 15 | import * as NodeOS from "node:os"; |
| 16 | import * as NodeReadlinePromises from "node:readline/promises"; |
| 17 | |
| 18 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 19 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 20 | import * as Config from "effect/Config"; |
| 21 | import * as Console from "effect/Console"; |
| 22 | import * as DateTime from "effect/DateTime"; |
| 23 | import * as Effect from "effect/Effect"; |
| 24 | import * as FileSystem from "effect/FileSystem"; |
| 25 | import * as Option from "effect/Option"; |
| 26 | import * as Path from "effect/Path"; |
| 27 | import * as Schema from "effect/Schema"; |
| 28 | import { Command, Flag } from "effect/cli"; |
| 29 | |
| 30 | import * as BootService from "../cloud/bootService.ts"; |
| 31 | import * as ServerConfig from "../config.ts"; |
| 32 | import { resolveBaseDir } from "../os-jank.ts"; |
| 33 | import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 34 | import { baseDirFlag } from "./config.ts"; |
| 35 | import { resolveCliCommand } from "./invocation.ts"; |
| 36 | import { |
| 37 | buildTriageContext, |
| 38 | buildTriageLaunchPrompt, |
| 39 | buildTriageSeedPrompt, |
| 40 | } from "./triagePrompt.ts"; |
| 41 | |
| 42 | interface TriageAgent { |
| 43 | readonly id: "claude" | "codex"; |
| 44 | readonly command: string; |
| 45 | readonly label: string; |
| 46 | } |
| 47 | |
| 48 | const TRIAGE_AGENTS: ReadonlyArray<TriageAgent> = [ |
| 49 | { id: "claude", command: "claude", label: "Claude Code" }, |
| 50 | { id: "codex", command: "codex", label: "Codex" }, |
| 51 | ]; |
| 52 | |
| 53 | export 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 | |
| 62 | export 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 | |
| 71 | export 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 | */ |
| 84 | export 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 | |
| 103 | const 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 | */ |
| 134 | const 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 | |
| 153 | const 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 | |
| 158 | const 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 | |
| 163 | export 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 | ); |