/** * `t3 triage` - hand a misbehaving install to the user's own coding agent. * * The command is deliberately thin: it writes a `context.md` with machine facts * (version, paths, server liveness), then launches claude or codex * interactively, seeded with the playbook from `triagePrompt.ts`. The agent * asks the user what went wrong, investigates, and files the issue; the * harness's own permission prompts gate anything it wants to run. With no * agent CLI installed, the prompt and context are written to disk for the user * to paste into whatever agent they do have. */// @effect-diagnostics nodeBuiltinImport:offimport * as NodeChildProcess from "node:child_process";import * as NodeOS from "node:os";import * as NodeReadlinePromises from "node:readline/promises"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";import * as Config from "effect/Config";import * as Console from "effect/Console";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" };import * as ServerConfig from "../config.ts";import { resolveBaseDir } from "../os-jank.ts";import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";import { baseDirFlag } from "./config.ts";import { resolveCliCommand } from "./invocation.ts";import { buildTriageContext, buildTriageLaunchPrompt, buildTriageSeedPrompt,} from "./triagePrompt.ts"; interface TriageAgent { readonly id: "claude" | "codex"; readonly command: string; readonly label: string;} const TRIAGE_AGENTS: ReadonlyArray<TriageAgent> = [ { id: "claude", command: "claude", label: "Claude Code" }, { id: "codex", command: "codex", label: "Codex" },]; export class TriageAgentUnavailableError extends Schema.TaggedError<TriageAgentUnavailableError>()( "TriageAgentUnavailableError", { agent: Schema.String },) { override get message(): string { return `\`${this.agent}\` is not installed or was not found on PATH.`; }} export class TriageAgentChoiceRequiredError extends Schema.TaggedError<TriageAgentChoiceRequiredError>()( "TriageAgentChoiceRequiredError", {},) { override get message(): string { 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."; }} export class TriageAgentSpawnError extends Schema.TaggedError<TriageAgentSpawnError>()( "TriageAgentSpawnError", { command: Schema.String, cause: Schema.Defect() },) { override get message(): string { return `Could not start \`${this.command}\`.`; }} /** One human-readable line about the local server, for `context.md`. */const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* ( serverRuntimeStatePath: string,) { // readPersistedServerRuntimeState swallows read/decode failures itself and // returns none, so a corrupt state file reads as "not running" here. const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath); if (Option.isNone(state)) { return "not running (no server-runtime.json; the server may never have started here)"; } if (!isProcessAlive(state.value.pid)) { return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin})`; } return `running (pid ${String(state.value.pid)}, ${state.value.origin})`;}); const pickAgent = (agents: ReadonlyArray<TriageAgent>) => Effect.promise(async () => { const readline = NodeReadlinePromises.createInterface({ input: process.stdin, output: process.stdout, }); try { const menu = agents .map((agent, index) => ` [${String(index + 1)}] ${agent.label}`) .join("\n"); for (;;) { const answer = (await readline.question(`Run triage with:\n${menu}\n> `)).trim(); const byNumber = agents[Number.parseInt(answer, 10) - 1]; if (byNumber !== undefined) { return byNumber; } const byId = agents.find((agent) => agent.id === answer.toLowerCase()); if (byId !== undefined) { return byId; } } } finally { readline.close(); } }); /** * Run the agent CLI as a normal interactive session: the user's terminal is * the UI, and the harness's own permission prompts gate every action. Resolves * with the child's exit code. */const runInteractiveSession = (input: { readonly command: string; readonly args: ReadonlyArray<string>; readonly shell: boolean; readonly cwd: string;}) => Effect.callback<number, TriageAgentSpawnError>((resume) => { const child = NodeChildProcess.spawn(input.command, [...input.args], { cwd: input.cwd, stdio: "inherit", shell: input.shell, }); child.once("error", (cause) => resume(Effect.fail(new TriageAgentSpawnError({ command: input.command, cause }))), ); // Signal death has no exit code; report failure rather than success. child.once("exit", (code, signal) => resume(Effect.succeed(code ?? (signal === null ? 0 : 1)))); }); const agentFlag = Flag.choice("agent", ["claude", "codex"]).pipe( Flag.withDescription("Agent CLI to use. Default: ask when both are installed."), Flag.optional,); const modelFlag = Flag.string("model").pipe( Flag.withDescription("Model passed through to the agent CLI. Default: the agent's default."), Flag.optional,); export const triageCommand = Command.make("triage", { baseDir: baseDirFlag, agent: agentFlag, model: modelFlag,}).pipe( Command.withDescription( "Investigate a T3 Code problem on this machine with claude or codex, and help file a good issue.", ), Command.withHandler((flags) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // Triage is a user-facing feature: always the userdata state, never dev. // --base-dir wins; T3CODE_HOME is its documented env equivalent (same // precedence as `t3 pair`). const explicitBaseDir = Option.getOrUndefined(flags.baseDir); const envHome = yield* Config.string("T3CODE_HOME").pipe(Config.option); const baseDir = yield* resolveBaseDir(explicitBaseDir ?? Option.getOrUndefined(envHome)); const paths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {}); const now = yield* DateTime.now; const scratchDir = path.join( paths.stateDir, "triage", // ISO instant, made safe for Windows paths. DateTime.formatIso(now).replaceAll(":", "-").replace(".", "-"), ); yield* fs.makeDirectory(scratchDir, { recursive: true }); const version = packageJson.version; const contextFilePath = path.join(scratchDir, "context.md"); yield* fs.writeFileString( contextFilePath, buildTriageContext({ generatedAt: DateTime.formatIso(now), version, releaseTag: version.includes("-nightly.") ? `v${version} (nightly build; if this tag does not exist, clone main)` : `v${version}`, os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`, nodeVersion: process.version, launchedAs: yield* resolveCliCommand("triage"), server: yield* describeServerProcess(paths.serverRuntimeStatePath), paths: { stateDir: paths.stateDir, dbPath: paths.dbPath, settingsPath: paths.settingsPath, logsDir: paths.logsDir, serverLogPath: paths.serverLogPath, serverTracePath: paths.serverTracePath, providerEventLogPath: paths.providerEventLogPath, terminalLogsDir: paths.terminalLogsDir, providerStatusCacheDir: paths.providerStatusCacheDir, secretsDir: paths.secretsDir, sourceCacheDir: path.join(baseDir, "source"), }, }), ); const installed: Array<TriageAgent> = []; for (const agent of TRIAGE_AGENTS) { if (yield* isCommandAvailable(agent.command)) { installed.push(agent); } } const requested = Option.getOrUndefined(flags.agent); let selected: TriageAgent | undefined; if (requested !== undefined) { selected = installed.find((agent) => agent.id === requested); if (selected === undefined) { return yield* new TriageAgentUnavailableError({ agent: requested }); } } else if (installed.length === 1) { selected = installed[0]; } else if (installed.length > 1) { // Both streams must be terminals: with stdout redirected the picker // prompt is invisible and the command would hang waiting on it. if (!process.stdin.isTTY || !process.stdout.isTTY) { return yield* new TriageAgentChoiceRequiredError(); } selected = yield* pickAgent(installed); } // The full seed prompt always goes to disk. The agent is launched with a // one-line pointer at it: Windows `.cmd` shims run through cmd.exe, // which cannot carry the multiline playbook as an argv string, and with // no agent installed the same file is the paste-anywhere fallback. const promptFilePath = path.join(scratchDir, "prompt.md"); yield* fs.writeFileString(promptFilePath, buildTriageSeedPrompt(contextFilePath)); if (selected === undefined) { yield* Console.log( [ "No supported agent CLI (claude, codex) was found on this machine.", "", "The triage prompt and machine context were written to:", ` ${promptFilePath}`, ` ${contextFilePath}`, "", "Paste the prompt file into any coding agent to run triage by hand.", ].join("\n"), ); return; } const model = Option.getOrUndefined(flags.model); const spawnSpec = yield* resolveSpawnCommand(selected.command, [ ...(model === undefined ? [] : ["--model", model]), buildTriageLaunchPrompt(promptFilePath), ]); yield* Console.log(`Starting ${selected.label}. It will ask what went wrong.\n`); const exitCode = yield* runInteractiveSession({ ...spawnSpec, cwd: scratchDir }); if (exitCode !== 0) { process.exitCode = exitCode; } }), ),);
/**
* `t3 triage` - hand a misbehaving install to the user's own coding agent.
*
* The command is deliberately thin: it writes a `context.md` with machine facts
* (version, paths, server liveness), then launches claude or codex
* interactively, seeded with the playbook from `triagePrompt.ts`. The agent
* asks the user what went wrong, investigates, and files the issue; the
* harness's own permission prompts gate anything it wants to run. With no
* agent CLI installed, the prompt and context are written to disk for the user
* to paste into whatever agent they do have.
*/
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeChildProcess from "node:child_process";
import * as NodeOS from "node:os";
import * as NodeReadlinePromises from "node:readline/promises";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import * as Config from "effect/Config";
import * as Console from "effect/Console";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { Command, Flag } from "effect/unstable/cli";
import packageJson from "../../package.json" with { type: "json" };
import * as ServerConfig from "../config.ts";
import { resolveBaseDir } from "../os-jank.ts";
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { baseDirFlag } from "./config.ts";
import { resolveCliCommand } from "./invocation.ts";
import {
buildTriageContext,
buildTriageLaunchPrompt,
buildTriageSeedPrompt,
} from "./triagePrompt.ts";
interface TriageAgent {
readonly id: "claude" | "codex";
readonly command: string;
readonly label: string;
}
const TRIAGE_AGENTS: ReadonlyArray<TriageAgent> = [
{ id: "claude", command: "claude", label: "Claude Code" },
{ id: "codex", command: "codex", label: "Codex" },
];
export class TriageAgentUnavailableError extends Schema.TaggedError<TriageAgentUnavailableError>()(
"TriageAgentUnavailableError",
{ agent: Schema.String },
) {
override get message(): string {
return `\`${this.agent}\` is not installed or was not found on PATH.`;
}
}
export class TriageAgentChoiceRequiredError extends Schema.TaggedError<TriageAgentChoiceRequiredError>()(
"TriageAgentChoiceRequiredError",
{},
) {
override get message(): string {
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.";
}
}
export class TriageAgentSpawnError extends Schema.TaggedError<TriageAgentSpawnError>()(
"TriageAgentSpawnError",
{ command: Schema.String, cause: Schema.Defect() },
) {
override get message(): string {
return `Could not start \`${this.command}\`.`;
}
}
/** One human-readable line about the local server, for `context.md`. */
const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* (
serverRuntimeStatePath: string,
) {
// readPersistedServerRuntimeState swallows read/decode failures itself and
// returns none, so a corrupt state file reads as "not running" here.
const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath);
if (Option.isNone(state)) {
return "not running (no server-runtime.json; the server may never have started here)";
}
if (!isProcessAlive(state.value.pid)) {
return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin})`;
}
return `running (pid ${String(state.value.pid)}, ${state.value.origin})`;
});
const pickAgent = (agents: ReadonlyArray<TriageAgent>) =>
Effect.promise(async () => {
const readline = NodeReadlinePromises.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const menu = agents
.map((agent, index) => ` [${String(index + 1)}] ${agent.label}`)
.join("\n");
for (;;) {
const answer = (await readline.question(`Run triage with:\n${menu}\n> `)).trim();
const byNumber = agents[Number.parseInt(answer, 10) - 1];
if (byNumber !== undefined) {
return byNumber;
}
const byId = agents.find((agent) => agent.id === answer.toLowerCase());
if (byId !== undefined) {
return byId;
}
}
} finally {
readline.close();
}
});
/**
* Run the agent CLI as a normal interactive session: the user's terminal is
* the UI, and the harness's own permission prompts gate every action. Resolves
* with the child's exit code.
*/
const runInteractiveSession = (input: {
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly shell: boolean;
readonly cwd: string;
}) =>
Effect.callback<number, TriageAgentSpawnError>((resume) => {
const child = NodeChildProcess.spawn(input.command, [...input.args], {
cwd: input.cwd,
stdio: "inherit",
shell: input.shell,
});
child.once("error", (cause) =>
resume(Effect.fail(new TriageAgentSpawnError({ command: input.command, cause }))),
);
// Signal death has no exit code; report failure rather than success.
child.once("exit", (code, signal) => resume(Effect.succeed(code ?? (signal === null ? 0 : 1))));
});
const agentFlag = Flag.choice("agent", ["claude", "codex"]).pipe(
Flag.withDescription("Agent CLI to use. Default: ask when both are installed."),
Flag.optional,
);
const modelFlag = Flag.string("model").pipe(
Flag.withDescription("Model passed through to the agent CLI. Default: the agent's default."),
Flag.optional,
);
export const triageCommand = Command.make("triage", {
baseDir: baseDirFlag,
agent: agentFlag,
model: modelFlag,
}).pipe(
Command.withDescription(
"Investigate a T3 Code problem on this machine with claude or codex, and help file a good issue.",
),
Command.withHandler((flags) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// Triage is a user-facing feature: always the userdata state, never dev.
// --base-dir wins; T3CODE_HOME is its documented env equivalent (same
// precedence as `t3 pair`).
const explicitBaseDir = Option.getOrUndefined(flags.baseDir);
const envHome = yield* Config.string("T3CODE_HOME").pipe(Config.option);
const baseDir = yield* resolveBaseDir(explicitBaseDir ?? Option.getOrUndefined(envHome));
const paths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {});
const now = yield* DateTime.now;
const scratchDir = path.join(
paths.stateDir,
"triage",
// ISO instant, made safe for Windows paths.
DateTime.formatIso(now).replaceAll(":", "-").replace(".", "-"),
);
yield* fs.makeDirectory(scratchDir, { recursive: true });
const version = packageJson.version;
const contextFilePath = path.join(scratchDir, "context.md");
yield* fs.writeFileString(
contextFilePath,
buildTriageContext({
generatedAt: DateTime.formatIso(now),
version,
releaseTag: version.includes("-nightly.")
? `v${version} (nightly build; if this tag does not exist, clone main)`
: `v${version}`,
os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`,
nodeVersion: process.version,
launchedAs: yield* resolveCliCommand("triage"),
server: yield* describeServerProcess(paths.serverRuntimeStatePath),
paths: {
stateDir: paths.stateDir,
dbPath: paths.dbPath,
settingsPath: paths.settingsPath,
logsDir: paths.logsDir,
serverLogPath: paths.serverLogPath,
serverTracePath: paths.serverTracePath,
providerEventLogPath: paths.providerEventLogPath,
terminalLogsDir: paths.terminalLogsDir,
providerStatusCacheDir: paths.providerStatusCacheDir,
secretsDir: paths.secretsDir,
sourceCacheDir: path.join(baseDir, "source"),
},
}),
);
const installed: Array<TriageAgent> = [];
for (const agent of TRIAGE_AGENTS) {
if (yield* isCommandAvailable(agent.command)) {
installed.push(agent);
}
}
const requested = Option.getOrUndefined(flags.agent);
let selected: TriageAgent | undefined;
if (requested !== undefined) {
selected = installed.find((agent) => agent.id === requested);
if (selected === undefined) {
return yield* new TriageAgentUnavailableError({ agent: requested });
}
} else if (installed.length === 1) {
selected = installed[0];
} else if (installed.length > 1) {
// Both streams must be terminals: with stdout redirected the picker
// prompt is invisible and the command would hang waiting on it.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return yield* new TriageAgentChoiceRequiredError();
}
selected = yield* pickAgent(installed);
}
// The full seed prompt always goes to disk. The agent is launched with a
// one-line pointer at it: Windows `.cmd` shims run through cmd.exe,
// which cannot carry the multiline playbook as an argv string, and with
// no agent installed the same file is the paste-anywhere fallback.
const promptFilePath = path.join(scratchDir, "prompt.md");
yield* fs.writeFileString(promptFilePath, buildTriageSeedPrompt(contextFilePath));
if (selected === undefined) {
yield* Console.log(
[
"No supported agent CLI (claude, codex) was found on this machine.",
"",
"The triage prompt and machine context were written to:",
` ${promptFilePath}`,
` ${contextFilePath}`,
"",
"Paste the prompt file into any coding agent to run triage by hand.",
].join("\n"),
);
return;
}
const model = Option.getOrUndefined(flags.model);
const spawnSpec = yield* resolveSpawnCommand(selected.command, [
...(model === undefined ? [] : ["--model", model]),
buildTriageLaunchPrompt(promptFilePath),
]);
yield* Console.log(`Starting ${selected.label}. It will ask what went wrong.\n`);
const exitCode = yield* runInteractiveSession({ ...spawnSpec, cwd: scratchDir });
if (exitCode !== 0) {
process.exitCode = exitCode;
}
}),
),
);