MagicCode/packages/cli/src/index.ts

631 lines
24 KiB
TypeScript
Raw Normal View History

2026-10-11 00:11:20 +00:00
import { homedir } from "node:os";
import { join } from "node:path";
import { parseArgs } from "node:util";
import {
2026-10-12 23:23:59 +00:00
SessionManager,
SessionStore,
2026-10-18 23:02:44 +00:00
compactThreshold,
2026-10-11 00:11:20 +00:00
createSession,
2026-10-12 23:23:59 +00:00
ensureMaiaHome,
2026-10-11 00:11:20 +00:00
findSkill,
2026-10-18 23:02:44 +00:00
isCompactable,
2026-10-12 23:23:59 +00:00
normalizeSessionName,
parseShellInvocation,
2026-10-12 23:23:59 +00:00
resolveFileReferences,
2026-10-11 00:11:20 +00:00
parseSkillInvocation,
2026-10-15 22:54:08 +00:00
queryContextWindow,
2026-10-18 23:02:44 +00:00
requestCompactSummary,
2026-10-11 00:11:20 +00:00
runTurn,
2026-10-18 23:02:44 +00:00
sessionContextTokens,
shouldCompact,
2026-10-11 00:11:20 +00:00
type AgentSession,
2026-10-12 23:23:59 +00:00
type Message,
} from "@maia/core";
import { initializeAgent, type AgentDefinition } from "@maia/agent";
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
import { InkUI } from "@maia/ui";
2026-10-11 00:11:20 +00:00
import { resolveConfig } from "./config";
import { resolveLaunchDir } from "./launch-dir";
import { runUserShell } from "./user-shell";
import { createSubAgentLauncher } from "./subagent-parent";
import { SUBAGENT_FLAG } from "./subagent-protocol";
2026-10-11 00:11:20 +00:00
2026-10-12 23:23:59 +00:00
const REPLAY_RESULT_MAX = 600;
const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints
2026-10-15 22:54:08 +00:00
const CONTEXT_QUERY_TIMEOUT_MS = 60_000; // question « taille maximale du contexte » posée au LLM
2026-10-12 23:23:59 +00:00
/** Réaffiche le contenu d'une session rechargée (échanges, appels d'outils, résultats tronqués). */
function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void {
const toolNames = new Map<string, string>();
for (const m of messages) {
for (const b of m.content) {
if (b.type === "text") {
if (!b.text.trim()) continue;
if (m.role === "user") ui.showUser(b.text.length > REPLAY_USER_MAX ? `${b.text.slice(0, REPLAY_USER_MAX)}… (tronqué)` : b.text);
else {
ui.onText(b.text);
ui.onTextEnd();
}
} else if (b.type === "tool_use") {
toolNames.set(b.id, b.name);
const tool = session.tools.get(b.name);
let summary: string;
try {
summary = tool?.describe?.(b.input) ?? JSON.stringify(b.input).slice(0, 120);
} catch {
summary = JSON.stringify(b.input).slice(0, 120);
}
ui.onToolCall(b.name, summary);
} else {
const out =
b.content.length > REPLAY_RESULT_MAX
? `${b.content.slice(0, REPLAY_RESULT_MAX)}… (tronqué)`
: b.content;
ui.onToolResult(toolNames.get(b.toolUseId) ?? "outil", out, b.isError === true);
}
}
}
}
const HELP = `MAIA — assistant IA en ligne de commande
2026-10-11 00:11:20 +00:00
Usage : maia [options]
--model <spec> Modèle de la session, ex. litellm:gpt-4o
2026-10-11 00:11:20 +00:00
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
-h, --help Affiche cette aide
Commandes en session : /help /model /permissions /agents /clear /compact /exit
2026-10-12 23:23:59 +00:00
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
2026-10-11 22:41:17 +00:00
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
2026-10-12 23:23:59 +00:00
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
2026-10-11 00:11:20 +00:00
async function main(): Promise<void> {
// Processus d'un sous-agent (lancé par l'agent principal) : pas d'interface, la mission arrive par IPC
if (process.argv.includes(SUBAGENT_FLAG)) {
const { runSubAgentChild } = await import("./subagent-child");
await runSubAgentChild();
return;
}
2026-10-11 00:11:20 +00:00
const { values } = parseArgs({
options: {
model: { type: "string" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(HELP);
return;
}
2026-10-11 22:41:17 +00:00
if (!process.stdin.isTTY) {
console.error("MAIA nécessite un terminal interactif (l'entrée standard n'est pas un TTY).");
2026-10-11 22:41:17 +00:00
process.exitCode = 1;
return;
}
const ui = new InkUI();
try {
await run(ui, { model: values.model });
2026-10-11 22:41:17 +00:00
} finally {
await ui.close(); // restaure le terminal et affiche les derniers messages
}
}
2026-10-11 00:11:20 +00:00
async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
2026-10-11 00:11:20 +00:00
// Dossier de lancement (voir launch-dir.ts) : base de tous les chemins relatifs
const cwd = resolveLaunchDir();
try {
process.chdir(cwd);
} catch (err) {
console.error(`Dossier de lancement inaccessible : ${cwd} (${(err as Error).message})`);
process.exitCode = 1;
return;
}
const homeDir = homedir();
// 0. Configuration de l'application : ~/.maia/settings.json (serveur LiteLLM)
let settings;
try {
settings = await loadSettings(homeDir);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
return;
}
2026-10-11 00:11:20 +00:00
// 2. Configuration
const config = await resolveConfig({
cwd,
homeDir,
2026-10-11 22:41:17 +00:00
flags,
2026-10-11 00:11:20 +00:00
});
/**
* Charge les modèles disponibles auprès de tous les providers, puis demande à l'utilisateur
* d'en choisir un. Retourne null si rien n'est disponible ou si l'utilisateur annule.
*/
const pickModel = async (current?: string): Promise<string | null> => {
const { choices, errors } = await ui.withSpinner(
"Chargement des modèles disponibles…",
() => loadModelChoices(settings),
2026-10-11 00:11:20 +00:00
"Modèles chargés",
);
for (const e of errors) ui.warn(e);
if (choices.length === 0) {
ui.error(
`Aucun modèle disponible auprès de LiteLLM. Vérifiez LiteLLM_URL et LiteLLM_Models dans ${settingsPath(homeDir)}.`,
2026-10-11 00:11:20 +00:00
);
return null;
}
return ui.select("Modèle", choices, current);
};
// Sous-agents : processus autonomes lancés par l'outil Agent (la notification INFO de fin passe par ui.info)
let agents: AgentDefinition[] = [];
const launchSubAgent = createSubAgentLauncher({
cwd,
homeDir,
settings,
getModel: () => modelSpec,
getSessionName: () => sessions?.name ?? null,
get store() {
return sessions?.store ?? null;
},
confirm: (m) => ui.confirm(m),
info: (m) => ui.info(m),
warn: (m) => ui.warn(m),
});
2026-10-18 23:02:44 +00:00
/**
* Démarre un agent. `startContext` (session compactée) : le résumé sert de contexte de départ à la place de MAIA.md / CLAUDE.md.
*/
const startSession = async (
modelSpec: string,
opts: { startContext?: string; quiet?: boolean } = {},
2026-10-18 23:02:44 +00:00
): Promise<AgentSession> => {
const init = await initializeAgent({
2026-10-11 00:11:20 +00:00
cwd,
homeDir,
2026-10-18 23:02:44 +00:00
log: (m) => (opts.quiet ? undefined : ui.info(m)),
2026-10-11 00:11:20 +00:00
warn: (m) => ui.warn(m),
2026-10-18 23:02:44 +00:00
startContext: opts.startContext,
launchSubAgent,
2026-10-11 00:11:20 +00:00
});
agents = init.agents;
2026-10-18 23:02:44 +00:00
const created = createSession({
provider: createProvider(modelSpec, settings),
init,
ui,
cwd,
startContext: opts.startContext,
});
showContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens
2026-10-15 22:54:08 +00:00
return created;
};
/**
* Compte les tokens du contexte de la session (js-tiktoken : prompt système, outils, messages, contexte en attente)
* et met la barre de progression à jour. Appelé avant CHAQUE saisie de l'utilisateur, et à chaque changement de session.
*/
2026-10-18 23:02:44 +00:00
const showContext = (target: AgentSession): void => {
ui.onContextUsage(sessionContextTokens(target));
2026-10-15 22:54:08 +00:00
};
// Tailles maximales de contexte déjà demandées au LLM (une seule question par modèle et par exécution)
const contextWindows = new Map<string, number>();
2026-10-18 23:02:44 +00:00
// Taille maximale du contexte du modèle actif (null : inconnue) : sert au seuil de compactage automatique
let contextMax: number | null = null;
2026-10-15 22:54:08 +00:00
/**
* Après la sélection d'un modèle : demande au LLM la taille maximale de son contexte (en tokens) et la donne à la barre
* de progression. Si la réponse est introuvable ou illisible, la barre est masquée (l'application continue).
*/
const resolveContextWindow = async (spec: string): Promise<void> => {
let max = contextWindows.get(spec) ?? null;
if (max === null) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONTEXT_QUERY_TIMEOUT_MS);
ui.setInterruptHandler(() => controller.abort()); // Ctrl+C abandonne la question
ui.setBusy("Recherche de la taille maximale du contexte…");
try {
const { tokens, answer } = await queryContextWindow(createProvider(spec, settings), controller.signal);
if (tokens === null) {
const shown = answer.length > 120 ? `${answer.slice(0, 120)}…` : answer;
ui.warn(`Taille maximale du contexte non déterminée (réponse du modèle : « ${shown} »). Barre de progression masquée.`);
} else {
max = tokens;
contextWindows.set(spec, tokens);
ui.info(`Taille maximale du contexte : ${tokens.toLocaleString("fr-FR")} tokens (réponse du modèle)`);
}
} catch (err) {
const why = controller.signal.aborted ? "question abandonnée (délai dépassé ou Ctrl+C)" : (err as Error).message;
ui.warn(`Taille maximale du contexte indisponible : ${why}. Barre de progression masquée.`);
} finally {
clearTimeout(timer);
ui.setBusy(null);
ui.setInterruptHandler(null);
}
}
2026-10-18 23:02:44 +00:00
contextMax = max;
2026-10-15 22:54:08 +00:00
ui.setContextMax(max);
2026-10-11 00:11:20 +00:00
};
2026-10-12 23:23:59 +00:00
ui.intro("Maia");
2026-10-11 00:11:20 +00:00
// Dossier de lancement : base de tous les chemins relatifs (fichiers lus, créés, configuration)
ui.info(`Dossier de travail : ${cwd}`);
2026-10-12 23:23:59 +00:00
// Dossier ./.maia (créé s'il n'existe pas) et ./.maia/.sessions : sessions communes à tous les agents
let sessions: SessionManager | null = null;
try {
const home = ensureMaiaHome(cwd);
if (home.created) ui.info(`Dossier créé : ${home.dir}`);
sessions = new SessionManager(new SessionStore(home.sessionsDir));
} catch (err) {
ui.warn(`Sessions désactivées : impossible de créer ./.maia/.sessions (${(err as Error).message})`);
}
2026-10-11 00:11:20 +00:00
// 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur
2026-10-15 22:54:08 +00:00
let modelSpec: string;
if (config.model) {
modelSpec = config.model;
} else {
2026-10-11 00:11:20 +00:00
const picked = await pickModel();
if (!picked) {
ui.outro("Aucun modèle choisi");
process.exitCode = 1;
return;
}
modelSpec = picked;
}
let session: AgentSession;
try {
session = await startSession(modelSpec);
2026-10-11 00:11:20 +00:00
} catch (err) {
ui.error((err as Error).message);
process.exitCode = 1;
return;
}
ui.info(`Modèle : ${modelSpec}`);
2026-10-15 22:54:08 +00:00
await resolveContextWindow(modelSpec);
2026-10-11 00:11:20 +00:00
2026-10-12 23:23:59 +00:00
// Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite.
const saveSession = (): string | null => {
if (!sessions) return null;
try {
const r = sessions.save(session, { model: modelSpec, cwd });
2026-10-12 23:23:59 +00:00
return r?.written ? r.name : null;
} catch (err) {
ui.warn(`Sauvegarde de la session impossible : ${(err as Error).message}`);
return null;
}
};
// Filet de sécurité : sortie par signal ou fermeture du terminal (l'écriture est synchrone)
const onExit = (): void => void saveSession();
const onSignal = (): void => {
saveSession();
process.exit(0);
};
process.on("exit", onExit);
process.on("SIGTERM", onSignal);
process.on("SIGHUP", onSignal);
// Quitte la session courante pour une nouvelle : l'utilisateur est prévenu, la session est sauvegardée.
const announceLeaving = (): void => {
if (!sessions) return;
if (sessions.isEmpty(session)) return;
const saved = saveSession();
const name = saved ?? sessions.name;
ui.warn(`Vous quittez la session « ${name} » : elle est sauvegardée (./.maia/.sessions/${name}.json).`);
};
2026-10-18 23:02:44 +00:00
/**
* Compactage (appel bloquant : la saisie reprend une fois terminé ; Ctrl+C l'annule sans rien perdre) :
* 1. la conversation actuelle est sauvegardée telle quelle sous son nom ;
* 2. le LLM en fait un résumé ;
* 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ;
* 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée.
*/
const compactNow = async (): Promise<boolean> => {
if (!isCompactable(session)) {
ui.info("Rien à compacter : la conversation est vide.");
return false;
}
const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort());
ui.setBusy("Compactage de la conversation…");
try {
saveSession(); // la session d'origine reste complète dans son fichier
const { summary, truncated } = await requestCompactSummary(session, controller.signal);
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
2026-10-18 23:02:44 +00:00
session = fresh;
let target = "";
if (sessions) {
const before = sessions.name;
target = sessions.startCompacted();
saveSession(); // « nom__n » : écrase un éventuel fichier du même nom
ui.info(`Conversation compactée : la session continue sous « ${target} » (« ${before} » est conservée telle quelle).`);
} else {
ui.info("Conversation compactée (sessions indisponibles : rien n'est enregistré).");
}
if (truncated) ui.warn("Le résumé a atteint la limite de 4000 tokens : il peut être incomplet.");
ui.info(`Contexte de départ :\n${summary}`);
return true;
} catch (err) {
ui.warn(
controller.signal.aborted
? "Compactage annulé (Ctrl+C) : la conversation est inchangée."
: `Compactage impossible, la conversation est inchangée : ${(err as Error).message}`,
);
return false;
} finally {
ui.setBusy(null);
ui.setInterruptHandler(null);
}
};
/** Compactage automatique dès que le contexte franchit Seuil_max = Max_Token_Context - (4000 + 500). */
const autoCompactIfNeeded = async (): Promise<void> => {
if (contextMax === null) return;
const tokens = sessionContextTokens(session);
if (!shouldCompact(tokens, contextMax)) return;
ui.warn(
`Contexte de ${tokens.toLocaleString("fr-FR")} tokens : seuil de ${compactThreshold(contextMax).toLocaleString("fr-FR")} franchi (maximum ${contextMax.toLocaleString("fr-FR")}). Compactage automatique…`,
);
await compactNow();
};
2026-10-11 00:11:20 +00:00
// 4. Boucle de conversation
for (;;) {
showContext(session); // taille du contexte recomptée avant chaque saisie
2026-10-11 00:11:20 +00:00
const input = await ui.readInput();
if (input === null || input === "/exit") break;
if (!input) continue;
// Commande shell directe (commune à tous les agents) : !commande
const shellCall = parseShellInvocation(input);
if (shellCall) {
if ("error" in shellCall) ui.error(shellCall.error);
2026-10-18 23:02:44 +00:00
else {
await runUserShell(ui, session, shellCall.command);
showContext(session); // le résultat de la commande est ajouté au contexte
}
continue;
}
2026-10-11 00:11:20 +00:00
let prompt = input;
const skillCall = parseSkillInvocation(input);
if (skillCall) {
// Appel direct d'un skill de l'agent chargé : §nom [arguments]
if (!skillCall.name) {
ui.info(
session.skills.length > 0
? ["Skills disponibles :", ...session.skills.map((s) => ` §${s.name} — ${s.description}`)].join("\n")
: "Aucun skill disponible disponible.",
2026-10-11 00:11:20 +00:00
);
continue;
}
const skill = findSkill(session.skills, skillCall.name);
if (!skill) {
const names = session.skills.map((s) => `§${s.name}`).join(", ") || "aucun";
ui.error(`Skill inconnu : §${skillCall.name} (disponibles : ${names})`);
continue;
}
try {
prompt = await skill.invoke(skillCall.args);
} catch (err) {
ui.error(`Échec du lancement du skill §${skill.name} : ${(err as Error).message}`);
continue;
}
ui.info(`Skill lancé : §${skill.name}`);
} else if (input.startsWith("/")) {
const [name = "", ...rest] = input.slice(1).split(" ");
const args = rest.join(" ");
if (name === "help") {
const custom = session.commands.map(
(c) => `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""} — ${c.description}`,
);
const skills = session.skills.map((s) => `§${s.name} — ${s.description}`);
ui.info([HELP, ...custom, ...skills].join("\n"));
continue;
}
2026-10-15 21:49:58 +00:00
if (name === "permissions") {
ui.showPermissions(session.permissionSources);
continue;
}
if (name === "agents") {
ui.info(
agents.length > 0
? [
"Sous-agents disponibles :",
...agents.map((a) => ` ${a.name} (${a.source}${a.model ? `, modèle ${a.model}` : ""}${a.trace ? ", trace" : ""}) — ${a.description}`),
].join("\n")
: "Aucun sous-agent : créez ./.maia/agents/<nom>/AGENT.md (ou ~/.maia/agents/<nom>/AGENT.md).",
);
continue;
}
2026-10-12 23:23:59 +00:00
if (name === "rename") {
if (!sessions) ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
else if (!args.trim()) ui.info(`Session courante : ${sessions.name} (usage : /rename <nouveau nom>)`);
else {
const r = sessions.rename(args);
if (!r.ok) ui.error(r.error);
else ui.info(r.changed ? `Session renommée : ${r.name}` : `La session s'appelle déjà « ${r.name} »`);
}
continue;
}
if (name === "new") {
if (!sessions) {
ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
continue;
}
announceLeaving();
2026-10-15 20:43:50 +00:00
try {
session = await startSession(modelSpec); // contexte (MAIA.md / CLAUDE.md) et skills rechargés
} catch (err) {
ui.error(`Nouvelle session impossible : ${(err as Error).message}`);
continue;
}
2026-10-12 23:23:59 +00:00
sessions.startNew();
ui.info(`Nouvelle session : ${sessions.name} (nouveau contexte)`);
continue;
}
if (name === "load") {
if (!sessions) {
ui.error("Sessions indisponibles (dossier ./.maia non accessible).");
continue;
}
let target = args.trim();
if (!target) {
const { sessions: list, unreadable } = await sessions.store.list();
for (const u of unreadable) ui.warn(`Session illisible ignorée : ${u}`);
if (list.length === 0) {
ui.info("Aucune session enregistrée.");
continue;
}
const picked = await ui.select(
"Sessions enregistrées",
list.map((x) => ({
value: x.name,
label: x.name,
hint: `${x.updatedAt.slice(0, 16).replace("T", " ")} · ${x.turns} échange${x.turns > 1 ? "s" : ""}${x.agent ? ` · sous-agent ${x.agent}` : ""}`,
2026-10-12 23:23:59 +00:00
})),
sessions.name,
);
if (!picked) continue;
target = picked;
}
const found = sessions.store.find(normalizeSessionName(target));
if (!found) {
const near = sessions.store.suggest(normalizeSessionName(target));
ui.error(`Session introuvable : ${target}${near.length ? ` (proches : ${near.join(", ")})` : ""}`);
continue;
}
if (found.toLowerCase() === sessions.name.toLowerCase() && !sessions.isEmpty(session)) {
ui.info(`« ${found} » est déjà la session courante.`);
continue;
}
let saved;
try {
saved = await sessions.store.read(found);
} catch (err) {
ui.error((err as Error).message);
continue;
}
announceLeaving();
2026-10-18 23:02:44 +00:00
if ((saved.startContext ?? null) !== (session.startContext ?? null)) {
// Session compactée (ou retour à une session normale) : le contexte de départ n'est pas le même
try {
session = await startSession(modelSpec, { startContext: saved.startContext, quiet: true });
2026-10-18 23:02:44 +00:00
} catch (err) {
ui.error(`Chargement impossible : ${(err as Error).message}`);
continue;
}
}
2026-10-12 23:23:59 +00:00
session.messages.length = 0;
session.messages.push(...saved.messages);
session.pendingContext.length = 0;
session.pendingContext.push(...saved.pendingContext);
sessions.restore(saved);
showContext(session);
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
2026-10-12 23:23:59 +00:00
if (session.pendingContext.length > 0) {
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`);
}
2026-10-18 23:02:44 +00:00
if (saved.startContext) ui.info(`Session compactée, contexte de départ :\n${saved.startContext}`);
2026-10-12 23:23:59 +00:00
replayMessages(ui, session, saved.messages);
ui.info("— fin de la session rechargée, vous pouvez continuer —");
continue;
}
2026-10-11 00:11:20 +00:00
if (name === "clear") {
2026-10-15 20:43:50 +00:00
// Contexte supprimé et agent réinitialisé : MAIA.md / CLAUDE.md et les skills sont rechargés
// comme au démarrage (la session garde son nom ; ce qui est effacé n'est pas sauvegardé).
try {
session = await startSession(modelSpec);
ui.info("Contexte supprimé : nouvelle conversation");
} catch (err) {
ui.error(`Réinitialisation impossible : ${(err as Error).message}`);
}
2026-10-11 00:11:20 +00:00
continue;
}
2026-10-18 23:02:44 +00:00
if (name === "compact") {
await compactNow();
continue;
}
2026-10-11 00:11:20 +00:00
if (name === "model") {
// Recharge la liste (les déploiements ont pu changer) ; la conversation en cours est conservée.
const choice = await pickModel(modelSpec);
if (choice && choice !== modelSpec) {
try {
session.provider = createProvider(choice, settings);
2026-10-11 00:11:20 +00:00
modelSpec = choice;
ui.info(`Modèle actif : ${modelSpec}`);
2026-10-15 22:54:08 +00:00
await resolveContextWindow(modelSpec);
2026-10-11 00:11:20 +00:00
} catch (err) {
ui.error((err as Error).message);
}
}
continue;
}
const custom = session.commands.find((c) => c.name === name);
if (!custom) {
ui.error(`Commande inconnue : /${name}`);
continue;
}
prompt = custom.prompt(args);
2026-10-11 00:11:20 +00:00
}
2026-10-18 23:02:44 +00:00
// Contexte trop proche de la limite : compactage (bloquant) avant d'envoyer la demande
await autoCompactIfNeeded();
2026-10-11 22:41:17 +00:00
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
const mark = session.messages.length;
const contextBackup = [...session.pendingContext];
2026-10-12 23:23:59 +00:00
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
// l'utilisateur a tapé est joint à la demande. Ajouté APRÈS la sauvegarde : une demande interrompue
// ne le conserve pas (on le re-citera en la relançant).
const refs = await resolveFileReferences(input, { cwd: session.cwd });
for (const w of refs.warnings) ui.warn(w);
if (refs.context) {
session.pendingContext.push(refs.context);
ui.info(
`Fichiers ajoutés au contexte : ${refs.files
.map((f) => `${f.path}${f.kind === "dir" ? "/" : ""}${f.truncated ? " (tronqué)" : ""}`)
.join(", ")}`,
);
}
2026-10-11 22:41:17 +00:00
const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort());
2026-10-11 00:11:20 +00:00
try {
2026-10-11 22:41:17 +00:00
await runTurn(session, prompt, controller.signal);
2026-10-11 00:11:20 +00:00
} catch (err) {
2026-10-11 22:41:17 +00:00
if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
showContext(session);
2026-10-11 22:41:17 +00:00
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
} else {
ui.error((err as Error).message);
}
} finally {
ui.setInterruptHandler(null);
2026-10-11 00:11:20 +00:00
}
2026-10-18 23:02:44 +00:00
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
await autoCompactIfNeeded();
2026-10-11 00:11:20 +00:00
}
2026-10-12 23:23:59 +00:00
const finalName = saveSession();
if (finalName) ui.info(`Session sauvegardée : ${finalName}`);
2026-10-11 00:11:20 +00:00
ui.outro("À bientôt");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});