diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 8f77f32..7eb808f 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -19,9 +19,11 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
-# Used directly for the sidecar-startup read timeout (tauri's async runtime is
-# tokio, but a transitive dep is not usable directly).
-tokio = { version = "1", features = ["time"] }
+# Used directly for the sidecar-startup read timeout and for the oneshot
+# channel that bridges the non-blocking folder picker's callback back to the
+# async `pick_directory` command (tauri's async runtime is tokio, but a
+# transitive dep is not usable directly).
+tokio = { version = "1", features = ["time", "sync"] }
[features]
# default to a production build without devtools
diff --git a/desktop/src-tauri/src/repo.rs b/desktop/src-tauri/src/repo.rs
index a6522ec..7568c6f 100644
--- a/desktop/src-tauri/src/repo.rs
+++ b/desktop/src-tauri/src/repo.rs
@@ -13,13 +13,29 @@ use std::process::Command;
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
+use tokio::sync::oneshot;
/// Opens the native folder picker and returns the chosen absolute path (or
-/// `None` if the user cancelled). Blocking is fine here: Tauri runs command
-/// handlers off the main thread, and the picker is modal by design.
+/// `None` if the user cancelled).
+///
+/// This command MUST be `async`. Tauri runs synchronous command handlers on the
+/// main (UI) thread, and the native folder dialog needs that same thread's event
+/// loop to pump. Calling the *blocking* picker from a sync command therefore
+/// deadlocks: the main thread blocks waiting for a dialog that can never run,
+/// freezing the whole app. Instead we use the non-blocking `pick_folder`, which
+/// shows the dialog on the main thread and invokes our callback when the user
+/// chooses; the callback hands the result back over a oneshot channel that this
+/// async handler (running off the main thread) awaits.
#[tauri::command]
-pub fn pick_directory(app: AppHandle) -> Result, String> {
- let picked = app.dialog().file().blocking_pick_folder();
+pub async fn pick_directory(app: AppHandle) -> Result , String> {
+ let (tx, rx) = oneshot::channel();
+ app.dialog().file().pick_folder(move |picked| {
+ // The receiver is only dropped if this command was cancelled; ignore.
+ let _ = tx.send(picked);
+ });
+ let picked = rx
+ .await
+ .map_err(|_| "folder picker was cancelled".to_string())?;
Ok(picked
.and_then(|p| p.into_path().ok())
.map(|p| p.to_string_lossy().to_string()))
@@ -27,8 +43,11 @@ pub fn pick_directory(app: AppHandle) -> Result , String> {
/// True when `path` is inside a git working tree. Mirrors the engine's
/// `isGitRepo` so the UI can validate a selection before a run is started.
+///
+/// `async` so the `git` subprocess runs off the main thread and never stalls the
+/// UI (e.g. on a cold filesystem cache or a slow/networked working copy).
#[tauri::command]
-pub fn check_git_repo(path: String) -> bool {
+pub async fn check_git_repo(path: String) -> bool {
Command::new("git")
.args(["-C", &path, "rev-parse", "--is-inside-work-tree"])
.output()
@@ -38,9 +57,12 @@ pub fn check_git_repo(path: String) -> bool {
/// For each requested tool name, reports whether it is found on PATH. Used by
/// the provider-onboarding panel to show "installed / missing" for tools like
-/// `codex`, `kiro`, and `gh`.
+/// `codex`, `kiro-cli`, and `gh`.
+///
+/// `async` so the PATH scan (filesystem stats per candidate) runs off the main
+/// thread and keeps the UI responsive.
#[tauri::command]
-pub fn which_commands(names: Vec) -> HashMap {
+pub async fn which_commands(names: Vec) -> HashMap {
names
.into_iter()
.map(|n| {
diff --git a/desktop/src/main.ts b/desktop/src/main.ts
index 20a5a4d..26cc12c 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -18,15 +18,15 @@ import {
startRun,
} from "./api.js";
import {
- MODEL_CATALOG,
+ PRESETS,
buildRunEnv,
- editsFiles,
- findModel,
+ getPreset,
loadSettings,
modelLabel,
saveSettings,
usesAdvancedRunners,
type ModelChoice,
+ type RunnerPreset,
} from "./settings.js";
import type { RunMessage, SessionRecord, TraceResponse } from "./types.js";
@@ -346,9 +346,9 @@ async function renderModels(): Promise {
);
view.append(page);
- // Availability: HTTP providers are unlocked by a stored API key (Tauri
- // keychain); CLI agents by an installed command. In a browser we can't see
- // either, so nothing is gated there.
+ // Availability: Responses presets are unlocked by a stored API key (Tauri
+ // keychain); CLI presets by an installed command (and, for Kiro, a key too).
+ // In a browser we can't see either, so nothing is gated there.
let storedKeys = new Set();
let knowKeys = false;
let installed: Record = {};
@@ -360,70 +360,111 @@ async function renderModels(): Promise {
/* unknown — show everything as selectable */
}
try {
- installed = await detectCommands(["codex", "kiro", "gh"]);
+ installed = await detectCommands(["codex", "kiro-cli", "gh"]);
} catch {
- /* unknown — don't flag CLI agents as missing */
+ /* unknown — don't flag CLI presets as missing */
}
}
- function providerMissing(p: (typeof MODEL_CATALOG)[number]): boolean {
- if (p.kind === "cli") return isTauri() && installed[p.command ?? ""] !== true;
- return knowKeys && !storedKeys.has(p.apiKeyEnv ?? "");
+ /** Whether a preset's command is known-missing (desktop only). */
+ function commandMissing(p: RunnerPreset): boolean {
+ return p.kind === "cli" && isTauri() && installed[p.command ?? ""] !== true;
}
- function providerMissingLabel(p: (typeof MODEL_CATALOG)[number]): string {
- return p.kind === "cli" ? `install ${p.command}` : `add ${p.apiKeyEnv}`;
+ /** Whether a preset's required API key is known-missing. */
+ function keyMissing(p: RunnerPreset): boolean {
+ const env = p.kind === "http-responses" ? p.apiKeyEnv : p.requiresEnv;
+ return env !== undefined && knowKeys && !storedKeys.has(env);
}
+ function presetMissing(p: RunnerPreset): boolean {
+ return commandMissing(p) || keyMissing(p);
+ }
+ function presetMissingLabel(p: RunnerPreset): string {
+ if (commandMissing(p)) return `install ${p.command}`;
+ const env = p.kind === "http-responses" ? p.apiKeyEnv : p.requiresEnv;
+ return `add ${env}`;
+ }
+
+ // One picker per role: a preset dropdown + (for configurable presets) an
+ // editable model id. Replaces the old provider/model matrix with the
+ // first-class presets.
+ function rolePicker(role: "writer" | "reviewer", note: HTMLElement): HTMLElement {
+ const wrap = h("div", { class: "preset-pick" });
+ const select = h("select", { "aria-label": `${role} preset` }) as HTMLSelectElement;
+ for (const preset of PRESETS) {
+ const missing = presetMissing(preset);
+ const opt = h("option", { value: preset.id }, [
+ missing ? `${preset.label} (${presetMissingLabel(preset)})` : preset.label,
+ ]) as HTMLOptionElement;
+ if (settings[role].preset === preset.id) opt.selected = true;
+ select.append(opt);
+ }
- function modelSelect(role: "writer" | "reviewer", onChange: () => void): HTMLSelectElement {
- const current = settings[role];
- const select = h("select", { "aria-label": `${role} model` }) as HTMLSelectElement;
- for (const provider of MODEL_CATALOG) {
- const missing = providerMissing(provider);
- const group = h("optgroup", { label: missing ? `${provider.label} (${providerMissingLabel(provider)})` : provider.label }) as HTMLOptGroupElement;
- for (const m of provider.models) {
- const opt = h("option", { value: `${provider.id}::${m.id}` }, [m.label]) as HTMLOptionElement;
- if (current.provider === provider.id && current.model === m.id) opt.selected = true;
- group.append(opt);
+ const modelInput = h("input", {
+ type: "text",
+ class: "model-id",
+ "aria-label": `${role} model id`,
+ autocomplete: "off",
+ spellcheck: "false",
+ }) as HTMLInputElement;
+
+ function syncModelInput(): void {
+ const preset = getPreset(settings[role].preset);
+ if (preset?.configurableModel) {
+ modelInput.hidden = false;
+ modelInput.placeholder = preset.defaultModel;
+ modelInput.value = settings[role].model || preset.defaultModel;
+ } else {
+ modelInput.hidden = true;
}
- select.append(group);
}
+
select.addEventListener("change", () => {
- const [p, m] = select.value.split("::");
- settings[role] = { provider: p!, model: m! } as ModelChoice;
+ // Reset the model to the new preset's default when switching presets.
+ settings[role] = { preset: select.value, model: "" } as ModelChoice;
+ persist();
+ syncModelInput();
+ setNote(note, role);
+ });
+ modelInput.addEventListener("input", () => {
+ settings[role] = { preset: settings[role].preset, model: modelInput.value.trim() };
persist();
- onChange();
+ setNote(note, role);
});
- return select;
+
+ syncModelInput();
+ wrap.append(select, modelInput);
+ return wrap;
}
function noteFor(role: "writer" | "reviewer", choice: ModelChoice): HTMLElement {
const note = h("div", { class: "model-note" });
- const found = findModel(choice);
- if (!found) return note;
- const { provider } = found;
- if (provider.kind === "http" && knowKeys && !storedKeys.has(provider.apiKeyEnv ?? "")) {
+ const preset = getPreset(choice.preset);
+ if (!preset) return note;
+
+ if (keyMissing(preset)) {
+ const env = preset.kind === "http-responses" ? preset.apiKeyEnv : preset.requiresEnv;
note.className = "model-note warn";
- const link = h("button", { type: "button", class: "linklike" }, [`Add ${provider.apiKeyEnv}`]);
+ const link = h("button", { type: "button", class: "linklike" }, [`Add ${env}`]);
link.addEventListener("click", () => navigate("secrets"));
- note.append(h("span", {}, [`No key stored for ${provider.label}. `]), link);
+ note.append(h("span", {}, [`No key stored for ${preset.label}. `]), link);
return note;
}
- if (provider.kind === "cli" && isTauri() && installed[provider.command ?? ""] !== true) {
+ if (commandMissing(preset)) {
note.className = "model-note warn";
- note.append(h("span", {}, [`${provider.command} is not installed — install it to use ${provider.label}.`]));
+ note.append(h("span", {}, [`${preset.command} is not installed — install it to use ${preset.label}.`]));
return note;
}
if (role === "writer") {
- if (editsFiles(choice)) {
+ if (preset.editsFiles) {
note.className = "model-note ok";
- note.append(h("span", {}, ["Edits files directly — runs can produce real, committable changes."]));
+ note.append(h("span", {}, [`${preset.authHint} Edits files directly — runs can produce real, committable changes.`]));
} else {
note.className = "model-note warn";
- note.append(h("span", {}, ["HTTP models return a diff but don't edit files. Pick a CLI writer (Codex / Kiro) to change a repo."]));
+ note.append(h("span", {}, ["Returns a diff but doesn't edit files. Pick a CLI writer (Codex CLI / Kiro CLI) to change a repo."]));
}
return note;
}
- note.append(h("span", {}, [provider.kind === "http" ? `Uses ${provider.apiKeyEnv}` : `Local command: ${provider.command}`]));
+ note.append(h("span", {}, [preset.authHint]));
return note;
}
@@ -434,8 +475,8 @@ async function renderModels(): Promise {
target.className = fresh.className;
target.replaceChildren(...Array.from(fresh.childNodes));
}
- const writerSelect = modelSelect("writer", () => setNote(writerNote, "writer"));
- const reviewerSelect = modelSelect("reviewer", () => setNote(reviewerNote, "reviewer"));
+ const writerSelect = rolePicker("writer", writerNote);
+ const reviewerSelect = rolePicker("reviewer", reviewerNote);
setNote(writerNote, "writer");
setNote(reviewerNote, "reviewer");
@@ -485,24 +526,42 @@ async function renderModels(): Promise {
]),
);
+ // -- Kiro CLI trusted tools (only meaningful for the kiro-cli preset).
+ const kiroTrustInput = h("input", {
+ type: "text",
+ value: settings.kiroTrustTools,
+ placeholder: "fs_read,fs_write,execute_bash (empty = --trust-all-tools)",
+ autocomplete: "off",
+ spellcheck: "false",
+ }) as HTMLInputElement;
+ kiroTrustInput.addEventListener("input", () => {
+ settings.kiroTrustTools = kiroTrustInput.value;
+ persist();
+ });
+ const kiroTrustField = h("label", { class: "inline-label" }, [
+ "Kiro CLI trusted tools",
+ kiroTrustInput,
+ ]);
+
const modelsCard = h("div", { class: "card" }, [
- h("h3", {}, ["Models"]),
+ h("h3", {}, ["Runner presets"]),
h("div", { class: "model-grid" }, [
h("div", { class: "model-pick" }, [
- h("label", { class: "model-pick-head" }, [icon(ICONS.write, "mp-ico"), "Writer", h("span", { class: "desc" }, ["Writes the code"])]),
+ h("label", { class: "model-pick-head" }, [icon(ICONS.write, "mp-ico"), "Writer", h("span", { class: "desc" }, ["Writes the code (actor)"])]),
writerSelect,
writerNote,
]),
h("div", { class: "model-pick" }, [
- h("label", { class: "model-pick-head" }, [icon(ICONS.review, "mp-ico"), "Reviewer", h("span", { class: "desc" }, ["Reviews the code"])]),
+ h("label", { class: "model-pick-head" }, [icon(ICONS.review, "mp-ico"), "Reviewer", h("span", { class: "desc" }, ["Reviews the code (critic)"])]),
reviewerSelect,
reviewerNote,
]),
]),
+ kiroTrustField,
h("div", { class: "desc keys-hint" }, [
isTauri()
- ? "HTTP models are unlocked by the API keys you store under Secrets; CLI agents must be installed locally."
- : "HTTP models are unlocked by the API keys provided in the engine's environment.",
+ ? "Codex CLI / Kiro CLI must be installed locally; the OpenAI Responses preset and Kiro CLI need their API keys stored under Secrets."
+ : "API-key presets are unlocked by the keys provided in the engine's environment; CLI presets must be installed on the engine host.",
]),
advancedDetails,
]);
@@ -669,7 +728,7 @@ async function renderModels(): Promise {
const envPanel = h("div", { class: "env-checks" });
if (isTauri()) {
envPanel.append(h("div", { class: "desc" }, ["Detected tools:"]));
- for (const name of ["codex", "kiro", "gh"]) {
+ for (const name of ["codex", "kiro-cli", "gh"]) {
const ok = installed[name] === true;
envPanel.append(h("span", { class: `tool ${ok ? "ok" : "missing"}` }, [`${name}: ${ok ? "installed" : "missing"}`]));
}
@@ -686,7 +745,144 @@ async function renderModels(): Promise {
envPanel,
]);
- page.append(repoCard, modelsCard, optionsCard, publishCard);
+ // -- Phone updates (Telegram). Desktop-only: stores the bot token + chat id
+ // as secrets the engine reads at startup. The engine connects OUTBOUND to
+ // Telegram (long-polling), so nothing inbound is ever exposed.
+ const tgCard = h("div", { class: "card" }, [
+ h("h3", {}, ["Phone updates (Telegram)"]),
+ h("div", { class: "desc" }, [
+ "Get a message when a run finishes, and reply with a new goal to keep the engine working. The engine only makes outbound calls to Telegram — it stays loopback-only and is never exposed.",
+ ]),
+ ]);
+ if (!isTauri()) {
+ tgCard.append(
+ h("div", { class: "desc" }, [
+ "Desktop only. In a browser, set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in the engine's environment.",
+ ]),
+ );
+ } else {
+ let hasToken = storedKeys.has("TELEGRAM_BOT_TOKEN");
+ let hasChat = storedKeys.has("TELEGRAM_CHAT_ID");
+ const tgStatus = h("div", { class: "hint" });
+ const paintTgStatus = (): void => {
+ tgStatus.textContent = `Bot token: ${hasToken ? "stored" : "not set"} · Chat ID: ${hasChat ? "stored" : "not set"}`;
+ };
+ paintTgStatus();
+ const tokenInput = h("input", {
+ type: "password",
+ autocomplete: "off",
+ placeholder: hasToken ? "•••••• stored — leave blank to keep" : "123456:ABC-DEF… (from @BotFather)",
+ }) as HTMLInputElement;
+ const chatInput = h("input", {
+ type: "text",
+ autocomplete: "off",
+ spellcheck: "false",
+ placeholder: hasChat ? "stored — leave blank to keep" : "your numeric chat id",
+ }) as HTMLInputElement;
+ const tgError = h("div", { class: "hint", hidden: "true" });
+ const saveBtn = h("button", { type: "button", class: "primary" }, ["Save & restart engine"]);
+ const removeBtn = h("button", { type: "button", class: "ghost" }, ["Remove"]);
+
+ // Re-reads the keychain index so the cached has* flags + status reflect what
+ // is actually stored after a mutation, rather than an optimistic guess.
+ const refreshTgStored = async (): Promise => {
+ try {
+ const keys = new Set(await listSecretKeys());
+ hasToken = keys.has("TELEGRAM_BOT_TOKEN");
+ hasChat = keys.has("TELEGRAM_CHAT_ID");
+ } catch {
+ /* leave cached flags as-is if the index can't be read */
+ }
+ paintTgStatus();
+ };
+
+ saveBtn.addEventListener("click", async () => {
+ tgError.hidden = true;
+ const tok = tokenInput.value.trim();
+ const chat = chatInput.value.trim();
+ // Both secrets are required for the relay to work — count an already-stored
+ // value as satisfied so a user editing one field need not re-enter both.
+ if (!tok && !hasToken) {
+ tgError.textContent = "Enter a bot token from @BotFather.";
+ tgError.className = "hint error";
+ tgError.hidden = false;
+ return;
+ }
+ if (!chat && !hasChat) {
+ tgError.textContent = "Enter your chat id (message @userinfobot to find it).";
+ tgError.className = "hint error";
+ tgError.hidden = false;
+ return;
+ }
+ if (chat && !/^-?\d+$/.test(chat)) {
+ tgError.textContent = "Chat ID must be a number (message @userinfobot to find yours).";
+ tgError.className = "hint error";
+ tgError.hidden = false;
+ return;
+ }
+ saveBtn.setAttribute("disabled", "true");
+ saveBtn.textContent = "Saving…";
+ try {
+ if (tok) await setSecret("TELEGRAM_BOT_TOKEN", tok);
+ if (chat) await setSecret("TELEGRAM_CHAT_ID", chat);
+ tokenInput.value = "";
+ chatInput.value = "";
+ const restarted = await restartEngineGuarded();
+ await refreshTgStored();
+ tgError.textContent = restarted
+ ? "Saved. The relay will start polling for your messages."
+ : "Saved — restart the engine (Secrets page) to apply.";
+ tgError.className = "hint ok";
+ tgError.hidden = false;
+ } catch (err) {
+ await refreshTgStored();
+ tgError.textContent = `Failed: ${(err as Error).message}`;
+ tgError.className = "hint error";
+ tgError.hidden = false;
+ } finally {
+ saveBtn.removeAttribute("disabled");
+ saveBtn.textContent = "Save & restart engine";
+ }
+ });
+
+ removeBtn.addEventListener("click", async () => {
+ tgError.hidden = true;
+ removeBtn.setAttribute("disabled", "true");
+ try {
+ await deleteSecret("TELEGRAM_BOT_TOKEN");
+ await deleteSecret("TELEGRAM_CHAT_ID");
+ const restarted = await restartEngineGuarded();
+ await refreshTgStored();
+ tgError.textContent = restarted
+ ? "Telegram notifications disabled."
+ : "Removed — restart the engine (Secrets page) to apply.";
+ tgError.className = "hint";
+ tgError.hidden = false;
+ } catch (err) {
+ await refreshTgStored();
+ tgError.textContent = `Failed: ${(err as Error).message}`;
+ tgError.className = "hint error";
+ tgError.hidden = false;
+ } finally {
+ removeBtn.removeAttribute("disabled");
+ }
+ });
+
+ tgCard.append(
+ tgStatus,
+ h("div", { class: "field-grid" }, [
+ h("label", { class: "inline-label" }, ["Bot token", tokenInput]),
+ h("label", { class: "inline-label" }, ["Chat ID", chatInput]),
+ ]),
+ h("div", { class: "actions" }, [saveBtn, removeBtn]),
+ tgError,
+ h("small", { class: "desc" }, [
+ "Saving restarts the engine to apply (you'll be warned if runs are active). The token + chat id are stored in your OS keychain.",
+ ]),
+ );
+ }
+
+ page.append(repoCard, modelsCard, optionsCard, publishCard, tgCard);
}
// --- Monitor view -----------------------------------------------------------
@@ -1166,25 +1362,25 @@ async function renderSecrets(): Promise {
const listEl = h("ul", { class: "secret-list" });
card.append(h("h3", {}, ["Stored keys"]), listEl);
- // Models-by-key: makes it explicit which models each environment key unlocks,
- // so the user can see exactly what a key buys them before (or after) adding it.
- const catalogCard = h("div", { class: "card" }, [h("h3", {}, ["Models by environment key"])]);
+ // Presets-by-key: makes it explicit which environment key unlocks which
+ // preset, so the user can see exactly what a key buys them before/after adding it.
+ const catalogCard = h("div", { class: "card" }, [h("h3", {}, ["Presets by environment key"])]);
const catalogList = h("div", { class: "key-catalog" });
catalogCard.append(catalogList);
function renderCatalog(stored: Set): void {
catalogList.innerHTML = "";
- for (const provider of MODEL_CATALOG) {
- if (provider.kind !== "http" || !provider.apiKeyEnv) continue; // CLI agents have no key
- const has = stored.has(provider.apiKeyEnv);
+ for (const preset of PRESETS) {
+ const env = preset.kind === "http-responses" ? preset.apiKeyEnv : preset.requiresEnv;
+ if (!env) continue; // e.g. codex-cli authenticates via `codex login`
+ const has = stored.has(env);
const head = h("div", { class: "key-cat-head" }, [
icon(ICONS.key, "key-cat-ico"),
- h("code", {}, [provider.apiKeyEnv]),
- h("span", { class: "key-cat-prov" }, [provider.label]),
+ h("code", {}, [env]),
+ h("span", { class: "key-cat-prov" }, [preset.label]),
h("span", { class: `key-cat-status ${has ? "on" : "off"}` }, [has ? "stored" : "not stored"]),
]);
- const models = h("div", { class: "key-cat-models" });
- for (const m of provider.models) models.append(h("span", { class: "model-tag" }, [m.label]));
+ const models = h("div", { class: "key-cat-models" }, [h("span", { class: "desc" }, [preset.description])]);
catalogList.append(h("div", { class: `key-cat${has ? " on" : ""}` }, [head, models]));
}
}
@@ -1339,6 +1535,30 @@ function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
}
+/**
+ * Restarts the engine, but first warns if runs are active (a restart re-spawns
+ * the sidecar and ABORTS in-flight runs). Returns true if the restart actually
+ * happened. Shared by the Secrets screen and the Telegram settings card so both
+ * use the same guard rather than silently killing runs.
+ */
+async function restartEngineGuarded(): Promise {
+ let active = 0;
+ try {
+ active = await activeRunCount();
+ } catch {
+ /* unknown — treat as nothing to lose */
+ }
+ if (active > 0) {
+ const ok = window.confirm(
+ `${active} run${active === 1 ? " is" : "s are"} still active. ` +
+ `Restarting the engine will abort ${active === 1 ? "it" : "them"}. Continue?`,
+ );
+ if (!ok) return false;
+ }
+ await restartEngine();
+ return true;
+}
+
async function checkEngine(): Promise {
const ok = await health();
engineLabel.textContent = ok ? "engine connected" : "engine offline";
diff --git a/desktop/src/settings.ts b/desktop/src/settings.ts
index 7f1c2d6..39bb2f6 100644
--- a/desktop/src/settings.ts
+++ b/desktop/src/settings.ts
@@ -1,124 +1,124 @@
/**
- * Run settings + the model catalog.
+ * Run settings + the runner-preset catalog.
*
* The engine itself is vendor-neutral: a run is configured purely by env vars
* (LOOPWRIGHT_RUNNERS + role bindings + publishing flags) plus an optional
* repo path. This module is the UI-side source of truth that turns a friendly
- * "which model writes / which model reviews" choice into those env vars, and
+ * "which preset writes / which preset reviews" choice into those env vars, and
* remembers every run-shaping preference between runs so the New run screen can
* stay a minimal goal box while nothing is lost.
*
- * HTTP models are grouped by the *environment key* (API key env var) that
- * unlocks them; CLI agents (Codex, Kiro) are grouped by the local command that
- * must be installed. CLI actors are the ones that actually edit files on disk —
- * HTTP runners only return a diff the engine does not apply.
+ * The catalog is a small, opinionated set of first-class RUNNER PRESETS rather
+ * than a generic provider/model matrix:
+ *
+ * - codex-cli `codex exec --json --model ` — a local CLI agent
+ * that edits files; best with a ChatGPT/Codex entitlement
+ * or an OpenAI key.
+ * - openai-responses the OpenAI Responses API (`/v1/responses`) for a model
+ * id like gpt-5.5 — returns a diff, does not edit files.
+ * - kiro-cli `kiro-cli chat --no-interactive --trust-tools=…` with
+ * KIRO_API_KEY — a local CLI agent that edits files.
+ *
+ * CLI presets are the ones that actually edit files on disk; the Responses
+ * preset only returns a diff the engine does not apply.
*/
-export type ProviderKind = "http" | "cli";
+/** Wire format each preset maps to (matches the engine's RunnerProfile.kind). */
+export type PresetKind = "cli" | "http-responses";
-export interface CatalogModel {
- /** model id sent to the provider (the runner profile's `model`) */
+export interface RunnerPreset {
+ /** stable preset id, e.g. "codex-cli" */
id: string;
- /** human label shown in the UI */
+ /** display name */
label: string;
-}
+ kind: PresetKind;
+ /** one-line description shown under the picker */
+ description: string;
+ /** default model id (the run uses this unless the user overrides it) */
+ defaultModel: string;
+ /** whether the user can edit the model id (false for account-driven CLIs) */
+ configurableModel: boolean;
+ /** true for presets that edit files directly (can produce real changes) */
+ editsFiles: boolean;
+ /** how this preset authenticates, shown in the UI */
+ authHint: string;
-export interface Provider {
- /** stable provider id, e.g. "openai" or "codex" */
- id: string;
- /** display name, e.g. "OpenAI" */
- label: string;
- kind: ProviderKind;
- /** http: OpenAI-compatible base URL the HttpRunner targets */
+ /** http-responses: base URL the ResponsesRunner targets */
baseUrl?: string;
- /** http: env var name that holds this provider's API key */
+ /** http-responses: request path (default "/responses") */
+ path?: string;
+ /** http-responses: env var holding the API key (also used for auth status) */
apiKeyEnv?: string;
+
/** cli: the command that must be on PATH (used for install detection) */
command?: string;
- /** true for CLI actors that edit files directly (can produce real changes) */
- editsFiles?: boolean;
- /** models this provider offers */
- models: CatalogModel[];
+ /** an env var the preset needs to authenticate (used for auth status) */
+ requiresEnv?: string;
}
/**
- * Real providers and models. HTTP providers are keyed by the environment
- * variable that holds the API key; CLI providers are local file-editing agents.
- * No placeholder/demo entries: every option here is something a user can
- * actually run once the matching key is stored or the command is installed.
+ * The three first-class presets. Every entry is something a user can actually
+ * run once the matching key is stored and/or the command is installed.
*/
-export const MODEL_CATALOG: Provider[] = [
- {
- id: "openai",
- label: "OpenAI",
- kind: "http",
- baseUrl: "https://api.openai.com/v1",
- apiKeyEnv: "OPENAI_API_KEY",
- models: [
- { id: "gpt-4o", label: "GPT-4o" },
- { id: "gpt-4o-mini", label: "GPT-4o mini" },
- { id: "gpt-4.1", label: "GPT-4.1" },
- { id: "gpt-4.1-mini", label: "GPT-4.1 mini" },
- { id: "o3", label: "o3" },
- { id: "o4-mini", label: "o4-mini" },
- ],
- },
- {
- id: "anthropic",
- label: "Anthropic",
- kind: "http",
- baseUrl: "https://api.anthropic.com/v1",
- apiKeyEnv: "ANTHROPIC_API_KEY",
- models: [
- { id: "claude-3-7-sonnet-latest", label: "Claude 3.7 Sonnet" },
- { id: "claude-3-5-sonnet-latest", label: "Claude 3.5 Sonnet" },
- { id: "claude-3-5-haiku-latest", label: "Claude 3.5 Haiku" },
- ],
- },
+export const PRESETS: RunnerPreset[] = [
{
- id: "google",
- label: "Google",
- kind: "http",
- baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
- apiKeyEnv: "GEMINI_API_KEY",
- models: [
- { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
- { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
- { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
- ],
- },
- {
- id: "codex",
+ id: "codex-cli",
label: "Codex CLI",
kind: "cli",
- command: "codex",
+ description: "Runs `codex exec --json --model ` locally and edits files directly.",
+ defaultModel: "gpt-5.5",
+ configurableModel: true,
editsFiles: true,
- models: [{ id: "codex", label: "Codex (edits files)" }],
+ authHint: "Sign in with `codex login` (ChatGPT Business / Codex) or set OPENAI_API_KEY.",
+ command: "codex",
},
{
- id: "kiro",
+ id: "openai-responses",
+ label: "OpenAI Responses API",
+ kind: "http-responses",
+ description: "Calls the OpenAI Responses API (/v1/responses). Returns a diff; does not edit files.",
+ defaultModel: "gpt-5.5",
+ configurableModel: true,
+ editsFiles: false,
+ authHint: "Needs OPENAI_API_KEY stored under Secrets.",
+ baseUrl: "https://api.openai.com/v1",
+ path: "/responses",
+ apiKeyEnv: "OPENAI_API_KEY",
+ },
+ {
+ id: "kiro-cli",
label: "Kiro CLI",
kind: "cli",
- command: "kiro",
+ description: "Runs `kiro-cli chat --no-interactive` headlessly and edits files directly.",
+ defaultModel: "",
+ configurableModel: false,
editsFiles: true,
- models: [{ id: "kiro", label: "Kiro (edits files)" }],
+ authHint: "Needs the kiro-cli command installed and KIRO_API_KEY stored under Secrets.",
+ command: "kiro-cli",
+ requiresEnv: "KIRO_API_KEY",
},
];
-/** A concrete model choice: which provider + which model id. */
+/** A concrete preset choice: which preset + (for configurable presets) model id. */
export interface ModelChoice {
- provider: string;
+ preset: string;
model: string;
}
export interface RunSettings {
/** absolute path of the local git repo the run builds against ("" = engine cwd) */
repo: string;
- /** model that writes the code (actor role) */
+ /** preset that writes the code (actor role) */
writer: ModelChoice;
- /** model that reviews the code (critic role) */
+ /** preset that reviews the code (critic role) */
reviewer: ModelChoice;
+ /**
+ * Tools kiro-cli is trusted with in headless mode (`--trust-tools=`).
+ * Empty falls back to `--trust-all-tools`. Only used by the kiro-cli preset.
+ */
+ kiroTrustTools: string;
+
/** how many tasks run at once */
maxParallel: number;
/** isolate each task in its own git worktree */
@@ -157,15 +157,16 @@ export interface RunSettings {
advancedCritic: string;
}
-const STORAGE_KEY = "loopwright.runSettings.v1";
-
-/** First provider that has at least one model — the baseline for defaults. */
-const firstProvider = MODEL_CATALOG[0]!;
+const STORAGE_KEY = "loopwright.runSettings.v2";
+/** Prior storage key; read once on upgrade so saved prefs aren't lost. */
+const LEGACY_STORAGE_KEY = "loopwright.runSettings.v1";
export const DEFAULT_SETTINGS: RunSettings = {
repo: "",
- writer: { provider: firstProvider.id, model: "gpt-4o-mini" },
- reviewer: { provider: firstProvider.id, model: "gpt-4o" },
+ // Writer edits files -> a CLI preset. Reviewer just reads -> Responses is fine.
+ writer: { preset: "codex-cli", model: "gpt-5.5" },
+ reviewer: { preset: "openai-responses", model: "gpt-5.5" },
+ kiroTrustTools: "fs_read,fs_write,execute_bash",
maxParallel: 2,
worktrees: true,
mechanicalGate: true,
@@ -184,26 +185,31 @@ export const DEFAULT_SETTINGS: RunSettings = {
advancedCritic: "",
};
-export function getProvider(id: string): Provider | undefined {
- return MODEL_CATALOG.find((p) => p.id === id);
-}
-
-export function findModel(choice: ModelChoice): { provider: Provider; model: CatalogModel } | undefined {
- const provider = getProvider(choice.provider);
- if (!provider) return undefined;
- const model = provider.models.find((m) => m.id === choice.model);
- if (!model) return undefined;
- return { provider, model };
+export function getPreset(id: string): RunnerPreset | undefined {
+ return PRESETS.find((p) => p.id === id);
}
-/** True when the choice resolves to a CLI agent that edits files on disk. */
+/** True when the choice resolves to a CLI preset that edits files on disk. */
export function editsFiles(choice: ModelChoice): boolean {
- return getProvider(choice.provider)?.editsFiles === true;
+ return getPreset(choice.preset)?.editsFiles === true;
}
-/** Just the model label, e.g. "GPT-4o mini" (falls back to the raw id). */
+/**
+ * A short, human label for a choice: the model id for presets with a
+ * configurable model (e.g. "gpt-5.5"), otherwise the preset label.
+ */
export function modelLabel(choice: ModelChoice): string {
- return findModel(choice)?.model.label ?? choice.model ?? "—";
+ const preset = getPreset(choice.preset);
+ if (!preset) return choice.model || "—";
+ if (preset.configurableModel) return (choice.model || preset.defaultModel) || preset.label;
+ return preset.label;
+}
+
+/** The effective model id a choice will run with (falls back to the default). */
+export function effectiveModel(choice: ModelChoice): string {
+ const preset = getPreset(choice.preset);
+ if (!preset) return choice.model;
+ return preset.configurableModel ? (choice.model || preset.defaultModel) : preset.defaultModel;
}
/** True when an advanced runner-profile override is active. */
@@ -211,24 +217,46 @@ export function usesAdvancedRunners(settings: RunSettings): boolean {
return settings.advancedRunners.trim() !== "";
}
+/** Normalizes a possibly-legacy/partial choice into a valid preset choice. */
+function coerceChoice(value: unknown, fallback: ModelChoice): ModelChoice {
+ if (value === null || typeof value !== "object") return { ...fallback };
+ const v = value as Record;
+ // Tolerate the legacy { provider, model } shape by mapping provider->preset.
+ const presetId = typeof v.preset === "string" ? v.preset : typeof v.provider === "string" ? v.provider : "";
+ const preset = getPreset(presetId);
+ if (!preset) return { ...fallback };
+ const model = typeof v.model === "string" ? v.model : "";
+ return { preset: preset.id, model };
+}
+
/** Loads saved settings, falling back to defaults and tolerating bad/legacy data. */
export function loadSettings(): RunSettings {
try {
- const raw = localStorage.getItem(STORAGE_KEY);
+ let raw = localStorage.getItem(STORAGE_KEY);
+ // Upgrade path: if there's no v2 payload yet, migrate the v1 one so a user's
+ // repo / publishing / run preferences survive. The provider->preset mapping
+ // happens in coerceChoice; everything else carries over by key. The migrated
+ // result is resaved under the v2 key below so this only runs once.
+ let migrating = false;
+ if (!raw) {
+ const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
+ if (legacy) {
+ raw = legacy;
+ migrating = true;
+ }
+ }
if (!raw) return { ...DEFAULT_SETTINGS };
- const parsed = JSON.parse(raw) as Partial;
+ const parsed = JSON.parse(raw) as Partial & Record;
const merged: RunSettings = {
...DEFAULT_SETTINGS,
...parsed,
- writer: { ...DEFAULT_SETTINGS.writer, ...(parsed.writer ?? {}) },
- reviewer: { ...DEFAULT_SETTINGS.reviewer, ...(parsed.reviewer ?? {}) },
+ writer: coerceChoice(parsed.writer, DEFAULT_SETTINGS.writer),
+ reviewer: coerceChoice(parsed.reviewer, DEFAULT_SETTINGS.reviewer),
};
- // Coerce persisted scalars: tampered or legacy localStorage could hold the
- // wrong type (e.g. maxParallel "abc", worktrees null), which would otherwise
- // flow into buildRunEnv() and fail run startup with invalid config.
const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d);
const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d);
merged.repo = str(merged.repo, DEFAULT_SETTINGS.repo);
+ merged.kiroTrustTools = str(merged.kiroTrustTools, DEFAULT_SETTINGS.kiroTrustTools);
merged.maxParallel =
Number.isFinite(merged.maxParallel) && merged.maxParallel >= 1
? Math.floor(merged.maxParallel)
@@ -248,10 +276,9 @@ export function loadSettings(): RunSettings {
merged.advancedRunners = str(merged.advancedRunners, DEFAULT_SETTINGS.advancedRunners);
merged.advancedActor = str(merged.advancedActor, DEFAULT_SETTINGS.advancedActor);
merged.advancedCritic = str(merged.advancedCritic, DEFAULT_SETTINGS.advancedCritic);
- // Drop selections that no longer exist in the catalog so the UI never shows
- // a stale/removed model as active.
- if (!findModel(merged.writer)) merged.writer = { ...DEFAULT_SETTINGS.writer };
- if (!findModel(merged.reviewer)) merged.reviewer = { ...DEFAULT_SETTINGS.reviewer };
+ // Persist the migrated payload under the new key so the legacy read only
+ // ever happens once.
+ if (migrating) saveSettings(merged);
return merged;
} catch {
return { ...DEFAULT_SETTINGS };
@@ -269,40 +296,57 @@ export function saveSettings(settings: RunSettings): void {
/** A runner profile as accepted by LOOPWRIGHT_RUNNERS. */
interface RunnerProfile {
id: string;
- kind: ProviderKind;
+ kind: "cli" | "http-responses";
model: string;
options: Record;
}
-function profileFor(id: string, choice: ModelChoice): RunnerProfile {
- const provider = getProvider(choice.provider) ?? firstProvider;
- if (provider.kind === "cli") {
- if (provider.id === "kiro") {
- return {
- id,
- kind: "cli",
- model: "",
- options: { command: "kiro", args: ["--headless", "--prompt", "{{prompt}}"], promptVia: "arg", output: { mode: "last-line" } },
- };
- }
- // Codex (default CLI shape)
+function profileFor(settings: RunSettings, id: string, choice: ModelChoice): RunnerProfile {
+ const preset = getPreset(choice.preset) ?? getPreset(DEFAULT_SETTINGS.writer.preset)!;
+
+ if (preset.id === "kiro-cli") {
+ const trust = settings.kiroTrustTools.trim();
+ const trustArg = trust ? `--trust-tools=${trust}` : "--trust-all-tools";
return {
id,
kind: "cli",
model: "",
+ options: {
+ command: "kiro-cli",
+ args: ["chat", "--no-interactive", trustArg, "{{prompt}}"],
+ promptVia: "arg",
+ // Headless Kiro prints the assistant's answer to stdout; the engine's
+ // JSON extraction tolerates surrounding log lines for structured calls.
+ output: { mode: "stdout" },
+ },
+ };
+ }
+
+ if (preset.kind === "cli") {
+ // codex-cli
+ return {
+ id,
+ kind: "cli",
+ model: effectiveModel(choice),
options: {
command: "codex",
- args: ["exec", "--json", "{{prompt}}"],
+ args: ["exec", "--json", "--model", "{{model}}", "{{prompt}}"],
promptVia: "arg",
output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" },
},
};
}
+
+ // openai-responses
return {
id,
- kind: "http",
- model: choice.model,
- options: { baseUrl: provider.baseUrl, apiKeyEnv: provider.apiKeyEnv },
+ kind: "http-responses",
+ model: effectiveModel(choice),
+ options: {
+ baseUrl: preset.baseUrl,
+ path: preset.path ?? "/responses",
+ apiKeyEnv: preset.apiKeyEnv,
+ },
};
}
@@ -323,7 +367,10 @@ export function buildRunEnv(settings: RunSettings): Record {
actor = settings.advancedActor.trim();
critic = settings.advancedCritic.trim();
} else {
- runnersJson = JSON.stringify([profileFor("writer", settings.writer), profileFor("reviewer", settings.reviewer)]);
+ runnersJson = JSON.stringify([
+ profileFor(settings, "writer", settings.writer),
+ profileFor(settings, "reviewer", settings.reviewer),
+ ]);
actor = "writer";
critic = "reviewer";
}
diff --git a/src/notify/telegram.ts b/src/notify/telegram.ts
new file mode 100644
index 0000000..893dbda
--- /dev/null
+++ b/src/notify/telegram.ts
@@ -0,0 +1,291 @@
+/**
+ * Telegram notify + control relay (loopback-safe).
+ *
+ * This is the "phone updates" layer. It does TWO things, both over OUTBOUND
+ * HTTPS to api.telegram.org — it never opens an inbound port, so the engine
+ * stays loopback-only and is never exposed publicly:
+ *
+ * 1. Push: when a run finishes, send the final status (and key links such as
+ * a PR url) to a single allowlisted Telegram chat.
+ * 2. Control: long-poll Telegram for replies from that same chat. A message
+ * is treated as a new goal and submitted as a run — reusing the most
+ * recent run's preset/repo configuration — so the user can keep the engine
+ * working from their phone. Only messages from the configured chat id are
+ * ever acted on.
+ *
+ * The Telegram transport (`fetch`) and the run submitter are injected, so the
+ * relay is unit-testable without a network or a live engine.
+ */
+
+import type { RunFinishedInfo, StartRunBody, SubmitResult } from "../server/server.js";
+
+export type { RunFinishedInfo, SubmitResult };
+
+/** The slice of the server the relay needs to drive runs from chat messages. */
+export interface RunSubmitter {
+ submitRun(body: StartRunBody): Promise;
+ /** the most recent run's env + repo, reused for chat-initiated runs */
+ lastRunConfig(): { env: Record; repoDir?: string } | undefined;
+}
+
+/** Minimal fetch shape the relay depends on (global fetch satisfies it). */
+export type TelegramFetch = (
+ url: string,
+ init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal },
+) => Promise<{ ok: boolean; status: number; json(): Promise; text(): Promise }>;
+
+export interface TelegramConfig {
+ /** bot token from @BotFather */
+ botToken: string;
+ /** the single chat id allowed to receive notifications and issue commands */
+ chatId: string;
+ /** Telegram Bot API base (override for tests); default https://api.telegram.org */
+ apiBase?: string;
+ /** injectable transport; defaults to the global fetch */
+ fetchImpl?: TelegramFetch;
+ log?: (line: string) => void;
+ /**
+ * When true (default), the first poll discards any backlog so the relay never
+ * acts on messages sent before it started. Tests set false to act immediately.
+ */
+ skipBacklog?: boolean;
+ /** long-poll timeout in seconds for getUpdates (default 50) */
+ pollTimeoutSec?: number;
+}
+
+/** Builds the final-status message for a finished run. Exported for tests. */
+export function formatFinalStatus(info: RunFinishedInfo): string {
+ const goalLine = `“${info.goal}”`;
+ if (info.phase === "error") {
+ return [`❌ Loopwright run failed`, goalLine, info.error ?? "unknown error", `session ${info.sessionId}`].join("\n");
+ }
+
+ const r = info.result;
+ const needsHuman = r?.needsHuman.length ?? 0;
+ const integrationBad = Boolean(r?.integration && r.integration.ok === false);
+ const head = needsHuman > 0 || integrationBad ? "⚠️ Loopwright: needs attention" : "✅ Loopwright: completed";
+
+ const lines: string[] = [head, goalLine];
+ if (r) {
+ lines.push(`green ${r.green.length} · unverified ${r.unverified.length} · needs-human ${r.needsHuman.length}`);
+ if (r.integration) {
+ lines.push(`integration: ${r.integration.ok ? "merged + verified" : "failed (conflicts or verification)"}`);
+ }
+ if (r.publish?.pushed) lines.push(`pushed → ${r.publish.remote}/${r.publish.pushBranch}`);
+ if (r.publish?.pr?.url) lines.push(`PR: ${r.publish.pr.url}`);
+ }
+ lines.push(`session ${info.sessionId}`);
+ if (needsHuman > 0 || integrationBad) {
+ lines.push("Reply with a new goal to keep me working.");
+ }
+ return lines.join("\n");
+}
+
+interface TgUpdate {
+ update_id: number;
+ message?: { text?: string; chat?: { id?: number | string } };
+}
+
+const HELP_TEXT =
+ "Loopwright relay\n" +
+ "Send any message and I'll run it as a new goal, reusing the repo + presets " +
+ "from your last run. Commands: /help.";
+
+export class TelegramRelay {
+ private readonly botToken: string;
+ private readonly chatId: string;
+ private readonly apiBase: string;
+ private readonly fetchImpl: TelegramFetch;
+ private readonly log: (line: string) => void;
+ private readonly pollTimeoutSec: number;
+
+ private submitter: RunSubmitter | undefined;
+ private offset: number | undefined;
+ private primed: boolean;
+ private running = false;
+ private abort: AbortController | undefined;
+
+ constructor(cfg: TelegramConfig) {
+ this.botToken = cfg.botToken;
+ this.chatId = String(cfg.chatId);
+ this.apiBase = (cfg.apiBase ?? "https://api.telegram.org").replace(/\/$/, "");
+ const globalFetch = (globalThis as { fetch?: TelegramFetch }).fetch;
+ const resolved = cfg.fetchImpl ?? globalFetch;
+ if (!resolved) throw new Error("TelegramRelay has no fetch implementation available.");
+ this.fetchImpl = resolved;
+ this.log = cfg.log ?? (() => {});
+ this.pollTimeoutSec = cfg.pollTimeoutSec ?? 50;
+ // primed=true means "act on the next batch immediately" (no backlog skip).
+ this.primed = cfg.skipBacklog === false;
+ }
+
+ /** Supplies the run submitter (the engine server). Call before start(). */
+ attach(submitter: RunSubmitter): void {
+ this.submitter = submitter;
+ }
+
+ /** Notifier hook: invoked by the server when a run reaches a terminal state. */
+ runFinished(info: RunFinishedInfo): void {
+ void this.sendMessage(formatFinalStatus(info)).catch((err) =>
+ this.log(`telegram: failed to send final status: ${String((err as Error)?.message ?? err)}`),
+ );
+ }
+
+ /** Begins the long-poll control loop (does nothing if already running). */
+ start(): void {
+ if (this.running) return;
+ this.running = true;
+ this.log("telegram: relay started (long-polling for messages)");
+ void this.loop();
+ }
+
+ /** Stops the control loop and aborts any in-flight long poll. */
+ stop(): void {
+ this.running = false;
+ this.abort?.abort();
+ }
+
+ private async loop(): Promise {
+ while (this.running) {
+ try {
+ await this.pollOnce();
+ } catch (err) {
+ if (!this.running) break;
+ this.log(`telegram: poll error: ${String((err as Error)?.message ?? err)}`);
+ await delay(3000);
+ }
+ }
+ }
+
+ /**
+ * Performs one getUpdates long-poll and handles the returned messages. The
+ * first batch is discarded (offset advanced only) unless backlog handling was
+ * enabled, so the relay never acts on messages sent before it started.
+ */
+ async pollOnce(): Promise {
+ const updates = await this.getUpdates();
+ const handleNow = this.primed;
+ for (const u of updates) {
+ // Acknowledge (advance the offset past) an update only AFTER it is handled
+ // successfully. If handling throws, leave it unacked and stop so Telegram
+ // re-delivers it on the next poll rather than silently dropping it.
+ if (handleNow) {
+ try {
+ await this.handleUpdate(u);
+ } catch (err) {
+ this.log(
+ `telegram: failed to handle update ${u.update_id}: ${String((err as Error)?.message ?? err)}`,
+ );
+ break;
+ }
+ }
+ this.offset = u.update_id + 1;
+ }
+ this.primed = true;
+ }
+
+ /** Acts on a single update: ignores other chats; runs the text as a goal. */
+ async handleUpdate(u: TgUpdate): Promise {
+ const msg = u.message;
+ const text = msg?.text?.trim();
+ if (!text) return;
+ // Allowlist: only the configured chat may receive output or issue commands.
+ if (String(msg?.chat?.id ?? "") !== this.chatId) {
+ this.log(`telegram: ignoring message from unauthorized chat ${String(msg?.chat?.id)}`);
+ return;
+ }
+
+ if (text === "/start" || text === "/help") {
+ await this.sendMessage(HELP_TEXT);
+ return;
+ }
+
+ if (!this.submitter) {
+ await this.sendMessage("Relay is not ready yet — try again in a moment.");
+ return;
+ }
+ const last = this.submitter.lastRunConfig();
+ if (!last) {
+ await this.sendMessage(
+ "I don't have a run configuration yet. Start one run from the desktop app first, then I'll reuse its repo + presets for messages from here.",
+ );
+ return;
+ }
+
+ const result = await this.submitter.submitRun({
+ goal: text,
+ env: last.env,
+ ...(last.repoDir ? { repoDir: last.repoDir } : {}),
+ });
+ if (result.ok) {
+ await this.sendMessage(`▶️ Starting run\n“${text}”\nsession ${result.sessionId}`);
+ } else {
+ await this.sendMessage(`Couldn't start that run (${result.status}): ${result.error}`);
+ }
+ }
+
+ /** Long-polls Telegram for new updates. Returns [] on a non-ok response. */
+ private async getUpdates(): Promise {
+ this.abort = new AbortController();
+ const params = new URLSearchParams({
+ timeout: String(this.pollTimeoutSec),
+ allowed_updates: JSON.stringify(["message"]),
+ });
+ if (this.offset !== undefined) params.set("offset", String(this.offset));
+ const url = `${this.apiBase}/bot${this.botToken}/getUpdates?${params.toString()}`;
+ const res = await this.fetchImpl(url, { method: "GET", signal: this.abort.signal });
+ if (!res.ok) {
+ // Throw so the loop applies its backoff: a persistent error (e.g. 401 on a
+ // bad token) would otherwise spin tightly, hammering Telegram.
+ throw new Error(`getUpdates returned ${res.status}`);
+ }
+ const body = (await res.json()) as { ok?: boolean; result?: TgUpdate[] };
+ return Array.isArray(body.result) ? body.result : [];
+ }
+
+ /** Sends a text message to the configured chat. Resolves true on success. */
+ async sendMessage(text: string): Promise {
+ const url = `${this.apiBase}/bot${this.botToken}/sendMessage`;
+ const res = await this.fetchImpl(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ chat_id: this.chatId, text, disable_web_page_preview: true }),
+ });
+ if (!res.ok) {
+ this.log(`telegram: sendMessage returned ${res.status}: ${await safeText(res)}`);
+ return false;
+ }
+ return true;
+ }
+}
+
+/**
+ * Builds a relay from the engine environment, or returns undefined when Telegram
+ * is not configured. Accepts both the explicit LOOPWRIGHT_TELEGRAM_* names and
+ * the plain TELEGRAM_* secret names (which the desktop shell can store and
+ * inject without the reserved LOOPWRIGHT_ prefix).
+ */
+export function createTelegramRelayFromEnv(
+ env: Record,
+ log?: (line: string) => void,
+): TelegramRelay | undefined {
+ const botToken = env.LOOPWRIGHT_TELEGRAM_BOT_TOKEN || env.TELEGRAM_BOT_TOKEN;
+ const chatId = env.LOOPWRIGHT_TELEGRAM_CHAT_ID || env.TELEGRAM_CHAT_ID;
+ if (!botToken || !chatId) return undefined;
+ return new TelegramRelay({ botToken, chatId, ...(log ? { log } : {}) });
+}
+
+async function safeText(res: { text(): Promise }): Promise {
+ try {
+ return await res.text();
+ } catch {
+ return "";
+ }
+}
+
+async function delay(ms: number): Promise {
+ return new Promise((resolve) => {
+ const t = setTimeout(resolve, ms);
+ if (typeof t.unref === "function") t.unref();
+ });
+}
diff --git a/src/runners/agentRunner.ts b/src/runners/agentRunner.ts
index adf658f..e731e72 100644
--- a/src/runners/agentRunner.ts
+++ b/src/runners/agentRunner.ts
@@ -8,16 +8,17 @@
*
* Adding a new backend = implement AgentRunner. Concrete runners are named by
* their MECHANISM, never by a product/vendor:
- * - CliRunner drives a headless command-line agent as a subprocess
- * - HttpRunner calls an OpenAI-compatible HTTP endpoint
- * - MockRunner deterministic, for tests
+ * - CliRunner drives a headless command-line agent as a subprocess
+ * - HttpRunner calls an OpenAI-compatible chat-completions endpoint
+ * - ResponsesRunner calls the OpenAI Responses API (`/v1/responses`)
+ * - MockRunner deterministic, for tests
* Supporting another provider is usually just a new RunnerProfile; a genuinely
* new transport is a new runner class. The engine never changes.
*/
import { z } from "zod";
-export type RunnerKind = "cli" | "http" | "mock";
+export type RunnerKind = "cli" | "http" | "http-responses" | "mock";
/**
* Validates a runner profile loaded from configuration. The {@link RunnerProfile}
@@ -27,7 +28,7 @@ export type RunnerKind = "cli" | "http" | "mock";
export const RunnerProfileSchema = z
.object({
id: z.string().min(1),
- kind: z.enum(["cli", "http", "mock"]),
+ kind: z.enum(["cli", "http", "http-responses", "mock"]),
model: z.string().default(""),
options: z.record(z.unknown()).optional(),
})
diff --git a/src/runners/responsesRunner.ts b/src/runners/responsesRunner.ts
new file mode 100644
index 0000000..6f10e6e
--- /dev/null
+++ b/src/runners/responsesRunner.ts
@@ -0,0 +1,286 @@
+import { z } from "zod";
+import type {
+ AgentRunner,
+ RunRequest,
+ RunResult,
+ RunnerProfile,
+} from "./agentRunner.js";
+import { redactAndTruncate } from "../engine/redaction.js";
+import type { FetchLike, HttpRunnerDeps } from "./httpRunner.js";
+
+/**
+ * Runner for the OpenAI **Responses API** (`POST {baseUrl}/responses`).
+ *
+ * This is intentionally a SEPARATE runner from {@link HttpRunner}, which speaks
+ * the older Chat Completions shape (`messages` -> `choices[].message.content`).
+ * The Responses API has a different request body (`input` + `instructions`) and
+ * a different result shape (`output[].content[].text`, with an `output_text`
+ * convenience), so mixing the two behind one runner would be fragile. Keeping
+ * them distinct means a profile's `kind` ("http" vs "http-responses") selects
+ * the exact wire format, and each can evolve independently.
+ *
+ * Everything provider-specific is profile data:
+ * - where to call: `baseUrl` + `path` (default `/responses`)
+ * - auth by reference: `apiKeyEnv` names an env var holding the key (sent as
+ * `Authorization: Bearer ...`); custom `headers` values may reference
+ * `${VAR}` from the parent env
+ * - the model comes from the profile's `model`
+ * - quota detection: HTTP status codes (default 429) and/or a body regex
+ * - a hard request timeout and bounded output capture
+ *
+ * Like the other network runner, transport/HTTP failures resolve to a result
+ * with diagnostics in `meta` rather than throwing, so one bad call never
+ * crashes a run; the role layer treats empty/unparseable output as a retry.
+ */
+
+const ENV_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
+
+function expandEnvRefs(value: string, source: NodeJS.ProcessEnv): string {
+ return value.replace(ENV_REF_RE, (_m, name: string) => source[name] ?? "");
+}
+
+export const ResponsesRunnerOptionsSchema = z
+ .object({
+ /** base URL of the API, e.g. "https://api.openai.com/v1" */
+ baseUrl: z.string().url(),
+ /** request path appended to baseUrl */
+ path: z.string().default("/responses"),
+ /** name of the env var holding the API key (secret by reference, not value) */
+ apiKeyEnv: z.string().optional(),
+ /** extra headers; values may contain ${VAR} expanded from the parent env */
+ headers: z.record(z.string()).default({}),
+ /** hard request timeout before the call is aborted */
+ timeoutMs: z.number().int().positive().default(5 * 60_000),
+ /** rolling cap on captured response text */
+ maxOutputChars: z.number().int().positive().default(2_000_000),
+ /** sampling temperature, omitted from the body when unset */
+ temperature: z.number().optional(),
+ /** max_output_tokens, omitted from the body when unset */
+ maxOutputTokens: z.number().int().positive().optional(),
+ /**
+ * Reasoning effort for reasoning models, e.g. "low" | "medium" | "high".
+ * Sent as `reasoning: { effort }` when set; omitted otherwise.
+ */
+ reasoningEffort: z.enum(["minimal", "low", "medium", "high"]).optional(),
+ /** merged into the request body (e.g. provider-specific knobs) */
+ extraBody: z.record(z.unknown()).default({}),
+ quota: z
+ .object({
+ /** HTTP status codes that indicate an exhausted usage/rate window */
+ statusCodes: z.array(z.number().int()).default([429]),
+ /** case-insensitive regex tested against an error response body */
+ pattern: z
+ .string()
+ .optional()
+ .refine(
+ (p) => {
+ if (p === undefined) return true;
+ try {
+ new RegExp(p, "i");
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ { message: "quota.pattern must be a valid regular expression" },
+ ),
+ })
+ .default({ statusCodes: [429] }),
+ })
+ .strict();
+
+export type ResponsesRunnerOptions = z.infer;
+
+/**
+ * Extracts the assistant text from a Responses API result. Prefers the
+ * top-level `output_text` convenience (a string, or an array of strings some
+ * gateways emit), then falls back to concatenating every `output_text` part
+ * found under `output[].content[]`. Anything unexpected yields "".
+ */
+export function extractResponsesText(body: unknown): string {
+ if (body === null || typeof body !== "object") return "";
+ const b = body as Record;
+
+ // 1) `output_text` convenience field.
+ const ot = b["output_text"];
+ if (typeof ot === "string" && ot.length > 0) return ot;
+ if (Array.isArray(ot)) {
+ const joined = ot.filter((x): x is string => typeof x === "string").join("");
+ if (joined.length > 0) return joined;
+ }
+
+ // 2) Walk output[] -> content[] -> { type: "output_text", text }.
+ const output = b["output"];
+ if (!Array.isArray(output)) return "";
+ const parts: string[] = [];
+ for (const item of output) {
+ if (item === null || typeof item !== "object") continue;
+ const content = (item as Record)["content"];
+ if (!Array.isArray(content)) continue;
+ for (const part of content) {
+ if (part === null || typeof part !== "object") continue;
+ const p = part as Record;
+ // Accept the canonical "output_text" and tolerate a bare {text}.
+ if ((p["type"] === undefined || p["type"] === "output_text") && typeof p["text"] === "string") {
+ parts.push(p["text"] as string);
+ }
+ }
+ }
+ return parts.join("");
+}
+
+/** Runner for the OpenAI Responses API. Transport is injectable for tests. */
+export class ResponsesRunner implements AgentRunner {
+ readonly profile: RunnerProfile;
+ private readonly opts: ResponsesRunnerOptions;
+ private readonly fetchImpl: FetchLike;
+ private readonly env: NodeJS.ProcessEnv;
+ private readonly quotaPattern?: RegExp;
+
+ constructor(profile: RunnerProfile, deps: HttpRunnerDeps = {}) {
+ this.profile = profile;
+ // Validate eagerly so a bad profile fails at construction, not mid-run.
+ this.opts = ResponsesRunnerOptionsSchema.parse(profile.options ?? {});
+ this.quotaPattern =
+ this.opts.quota.pattern !== undefined
+ ? new RegExp(this.opts.quota.pattern, "i")
+ : undefined;
+ const globalFetch = (globalThis as { fetch?: FetchLike }).fetch;
+ const resolved = deps.fetch ?? globalFetch;
+ if (!resolved) {
+ throw new Error(
+ `ResponsesRunner "${profile.id}" has no fetch implementation available ` +
+ `(global fetch missing; pass one via deps.fetch).`,
+ );
+ }
+ this.fetchImpl = resolved;
+ this.env = deps.env ?? process.env;
+ }
+
+ async run(req: RunRequest): Promise {
+ const url = this.joinUrl(this.opts.baseUrl, this.opts.path);
+ const headers = this.buildHeaders();
+ const body = JSON.stringify(this.buildBody(req));
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
+ const started = Date.now();
+
+ const onAbort = (): void => controller.abort();
+ if (req.signal) {
+ if (req.signal.aborted) controller.abort();
+ else req.signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ try {
+ const res = await this.fetchImpl(url, {
+ method: "POST",
+ headers,
+ body,
+ signal: controller.signal,
+ });
+ const durationMs = Date.now() - started;
+
+ if (!res.ok) {
+ const errText = await safeText(res);
+ const quotaExhausted =
+ this.opts.quota.statusCodes.includes(res.status) ||
+ (this.quotaPattern !== undefined && this.quotaPattern.test(errText));
+ return {
+ text: "",
+ quotaExhausted,
+ meta: {
+ runnerId: this.profile.id,
+ model: this.profile.model,
+ status: res.status,
+ durationMs,
+ error: redactAndTruncate(errText, 2_000),
+ },
+ };
+ }
+
+ const json = await res.json();
+ const raw = extractResponsesText(json);
+ const text =
+ raw.length > this.opts.maxOutputChars ? raw.slice(0, this.opts.maxOutputChars) : raw;
+
+ return {
+ text,
+ quotaExhausted: false,
+ meta: {
+ runnerId: this.profile.id,
+ model: this.profile.model,
+ status: res.status,
+ durationMs,
+ usage: (json as { usage?: unknown })?.usage ?? null,
+ },
+ };
+ } catch (err) {
+ const cancelled = req.signal?.aborted ?? false;
+ const aborted = controller.signal.aborted;
+ return {
+ text: "",
+ quotaExhausted: false,
+ meta: {
+ runnerId: this.profile.id,
+ model: this.profile.model,
+ durationMs: Date.now() - started,
+ timedOut: aborted && !cancelled,
+ cancelled,
+ error: cancelled
+ ? "request cancelled"
+ : aborted
+ ? "request timed out"
+ : String((err as Error).message ?? err),
+ },
+ };
+ } finally {
+ clearTimeout(timer);
+ req.signal?.removeEventListener("abort", onAbort);
+ }
+ }
+
+ private joinUrl(base: string, path: string): string {
+ const b = base.endsWith("/") ? base.slice(0, -1) : base;
+ const p = path.startsWith("/") ? path : `/${path}`;
+ return `${b}${p}`;
+ }
+
+ private buildHeaders(): Record {
+ const headers: Record = { "content-type": "application/json" };
+ for (const [k, v] of Object.entries(this.opts.headers)) {
+ headers[k] = expandEnvRefs(v, this.env);
+ }
+ if (this.opts.apiKeyEnv) {
+ const key = this.env[this.opts.apiKeyEnv];
+ if (key) headers["authorization"] = `Bearer ${key}`;
+ }
+ return headers;
+ }
+
+ private buildBody(req: RunRequest): Record {
+ const body: Record = {
+ // Spread caller-supplied knobs FIRST so they can never override the core
+ // request fields (model/input) set below — otherwise a stray extraBody
+ // value could silently diverge the sent prompt/model from the request.
+ ...this.opts.extraBody,
+ model: this.profile.model,
+ // The Responses API takes `input` (the user turn) and a separate
+ // `instructions` (the system framing), unlike Chat Completions' messages.
+ input: req.prompt,
+ };
+ if (req.system) body["instructions"] = req.system;
+ if (this.opts.temperature !== undefined) body["temperature"] = this.opts.temperature;
+ if (this.opts.maxOutputTokens !== undefined) body["max_output_tokens"] = this.opts.maxOutputTokens;
+ if (this.opts.reasoningEffort !== undefined) body["reasoning"] = { effort: this.opts.reasoningEffort };
+ return body;
+ }
+}
+
+async function safeText(res: { text(): Promise }): Promise {
+ try {
+ return await res.text();
+ } catch {
+ return "";
+ }
+}
diff --git a/src/runners/runnerFactory.ts b/src/runners/runnerFactory.ts
index fffcf8b..14d441f 100644
--- a/src/runners/runnerFactory.ts
+++ b/src/runners/runnerFactory.ts
@@ -1,6 +1,7 @@
import type { AgentRunner, RunnerProfile } from "./agentRunner.js";
import { CliRunner } from "./cliRunner.js";
import { HttpRunner } from "./httpRunner.js";
+import { ResponsesRunner } from "./responsesRunner.js";
import { MockRunner } from "./mockRunner.js";
/**
@@ -30,6 +31,8 @@ export const createRunner: RunnerFactory = (profile) => {
return MockRunner.fromProfile(profile);
case "http":
return new HttpRunner(profile);
+ case "http-responses":
+ return new ResponsesRunner(profile);
default: {
// Exhaustiveness guard: a new RunnerKind must be handled above.
const exhaustive: never = profile.kind;
diff --git a/src/server/index.ts b/src/server/index.ts
index 493db86..0a8fe8a 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -25,6 +25,7 @@ import { loadConfig } from "../config.js";
import { openStore } from "../storage/store.js";
import { reconcileInterruptedSessions } from "../session.js";
import { createServer, isLoopbackHost } from "./server.js";
+import { createTelegramRelayFromEnv } from "../notify/telegram.js";
function envFlag(v: string | undefined): boolean {
return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
@@ -49,15 +50,29 @@ async function main(): Promise {
// The supervising process (Tauri) may pin the token via env; otherwise a
// fresh random one is generated and reported on the readiness line.
const token = process.env.LOOPWRIGHT_TOKEN || randomUUID();
+
+ // Optional phone updates: a Telegram relay that pushes final run status and
+ // accepts replies (as new goals) over OUTBOUND long-polling only — no inbound
+ // port, so the engine stays loopback-only. Absent config => no relay.
+ const relay = createTelegramRelayFromEnv(process.env, (line) => console.error(line));
+
const server = createServer({
store,
config,
token,
// When a graceful shutdown finishes (signal or POST /api/shutdown), exit.
onShutdown: () => process.exit(0),
+ ...(relay ? { notifier: relay } : {}),
...(staticDir ? { staticDir } : {}),
});
+ // The relay needs to launch chat-initiated runs through the same validated
+ // path the UI uses; hand it the server once both exist.
+ if (relay) {
+ relay.attach(server);
+ relay.start();
+ }
+
const port = Number.parseInt(process.env.LOOPWRIGHT_PORT ?? "0", 10) || 0;
const host = process.env.LOOPWRIGHT_HOST ?? "127.0.0.1";
@@ -85,6 +100,7 @@ async function main(): Promise {
const shutdown = (): void => {
if (stopping) return;
stopping = true;
+ relay?.stop();
void server.stop().then(() => process.exit(0));
};
process.on("SIGINT", shutdown);
diff --git a/src/server/server.ts b/src/server/server.ts
index 163b63e..32860c6 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -60,6 +60,30 @@ export type RunGoalImpl = (
opts?: RunGoalOptions,
) => Promise;
+/** Outcome of submitting a run programmatically (HTTP route or relay). */
+export type SubmitResult =
+ | { ok: true; sessionId: string }
+ | { ok: false; status: number; error: string };
+
+/** Info passed to a notifier when a run reaches a terminal state. */
+export interface RunFinishedInfo {
+ sessionId: string;
+ goal: string;
+ phase: "done" | "error";
+ result?: SessionResult;
+ error?: string;
+}
+
+/**
+ * Optional sink for terminal run status (e.g. the Telegram relay). It is
+ * deliberately outbound-only and best-effort: the server never lets a notifier
+ * error affect a run, and a notifier is the ONLY way run status leaves the
+ * loopback boundary.
+ */
+export interface ServerNotifier {
+ runFinished(info: RunFinishedInfo): void;
+}
+
export interface CreateServerOptions {
/** durable store shared by runs (writes) and the trace endpoint (reads) */
store: Store;
@@ -96,6 +120,12 @@ export interface CreateServerOptions {
* force-closing any that remain (idle keep-alive sockets, slow clients).
*/
shutdownGraceMs?: number;
+ /**
+ * Optional outbound notifier for terminal run status (e.g. a Telegram relay).
+ * The server keeps no inbound exposure; this is the only channel by which run
+ * status leaves the process.
+ */
+ notifier?: ServerNotifier;
}
export interface LoopwrightServer {
@@ -107,6 +137,13 @@ export interface LoopwrightServer {
/** listen on a port (0 = ephemeral) and resolve with the bound port */
start(port?: number, host?: string): Promise;
stop(): Promise;
+ /**
+ * Starts a run programmatically (same path as POST /api/runs), without going
+ * through HTTP. Used by the Telegram relay to launch chat-initiated runs.
+ */
+ submitRun(body: StartRunBody): Promise;
+ /** Env + repo of the most recent run, for chat-initiated runs to reuse. */
+ lastRunConfig(): { env: Record; repoDir?: string } | undefined;
}
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
@@ -210,8 +247,11 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
const retainMs = opts.retainMs ?? 5 * 60_000;
const onShutdown = opts.onShutdown;
const shutdownGraceMs = opts.shutdownGraceMs ?? 3_000;
+ const notifier = opts.notifier;
const hub = new RunHub(opts.maxBufferPerRun);
let activeRuns = 0;
+ /** env + repo of the most recent run, reused for chat-initiated runs */
+ let lastRun: { env: Record; repoDir?: string } | undefined;
/** abort controllers for in-flight runs, keyed by session id (for cancel) */
const controllers = new Map();
/** open SSE responses, so a graceful shutdown can end them deterministically */
@@ -347,8 +387,23 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
} catch (err) {
return send(res, 400, { error: `invalid JSON body: ${String((err as Error).message)}` }, cors);
}
+ const result = await submitRun(body);
+ if (!result.ok) return send(res, result.status, { error: result.error }, cors);
+ return send(res, 202, { sessionId: result.sessionId }, cors);
+ }
+
+ /**
+ * Core run-start logic, shared by the HTTP route and programmatic callers
+ * (the Telegram relay). Validates the request and kicks the run off in the
+ * background, returning a structured result instead of writing to a response.
+ * The fire-and-forget run streams progress over the hub and, on completion,
+ * notifies any configured outbound notifier.
+ */
+ async function submitRun(body: StartRunBody): Promise {
+ if (closing) return { ok: false, status: 503, error: "server is shutting down" };
+
const goal = (body.goal ?? "").trim();
- if (!goal) return send(res, 400, { error: "goal is required" }, cors);
+ if (!goal) return { ok: false, status: 400, error: "goal is required" };
// Resolve per-run config from the merged env, but never let a caller
// redirect persistence away from the server's store.
@@ -358,62 +413,51 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
try {
runConfig = loadConfig(mergedEnv);
} catch (err) {
- return send(res, 400, { error: `invalid config: ${String((err as Error).message)}` }, cors);
+ return { ok: false, status: 400, error: `invalid config: ${String((err as Error).message)}` };
}
runConfig.dbPath = config.dbPath;
// Effective repo: the per-run body value wins, falling back to the env
- // default. The body is untyped JSON, so reject a non-string up front (a
- // number would otherwise throw on .trim() and turn a bad request into a
- // 500). It must be an absolute path (the field is documented as such, and a
- // relative path would resolve against the server's cwd, not the caller's),
- // and it must be a git working tree — validate before the run so it fails
- // with a clear 400 rather than deep inside worktree setup.
+ // default. The body is untyped JSON, so reject a non-string up front. It
+ // must be an absolute path and a git working tree — validated before the
+ // run so it fails clearly rather than deep inside worktree setup.
if (body.repoDir !== undefined && typeof body.repoDir !== "string") {
- return send(res, 400, { error: "repoDir must be a string" }, cors);
+ return { ok: false, status: 400, error: "repoDir must be a string" };
}
const repoDir =
(typeof body.repoDir === "string" ? body.repoDir.trim() : "") || runConfig.repoDir.trim();
if (repoDir) {
if (!path.isAbsolute(repoDir)) {
- return send(res, 400, { error: `repoDir must be an absolute path (got "${repoDir}")` }, cors);
+ return { ok: false, status: 400, error: `repoDir must be an absolute path (got "${repoDir}")` };
}
if (!(await isGitRepo(repoDir))) {
- return send(
- res,
- 400,
- { error: `repoDir "${repoDir}" is not a git repository (run git init or pick another folder)` },
- cors,
- );
+ return {
+ ok: false,
+ status: 400,
+ error: `repoDir "${repoDir}" is not a git repository (run git init or pick another folder)`,
+ };
}
}
// A caller-supplied session id becomes part of git branch names and
- // worktree paths, so reject anything outside the bounded safe format before
- // it reaches the filesystem/git. New runs without an id get a safe UUID.
+ // worktree paths, so reject anything outside the bounded safe format.
if (body.sessionId !== undefined && !isValidSessionId(body.sessionId)) {
- return send(
- res,
- 400,
- { error: "sessionId must match /^[A-Za-z0-9_-]{1,64}$/" },
- cors,
- );
+ return { ok: false, status: 400, error: "sessionId must match /^[A-Za-z0-9_-]{1,64}$/" };
}
const sessionId = body.sessionId ?? randomUUID();
if (hub.has(sessionId) && hub.phase(sessionId) === "running") {
- return send(res, 409, { error: `session ${sessionId} is already running` }, cors);
+ return { ok: false, status: 409, error: `session ${sessionId} is already running` };
}
// Admission control: cap concurrent background runs so repeated clicks, a
// buggy UI, or a leaked token can't kick off unbounded expensive work.
if (activeRuns >= maxActiveRuns) {
- return send(
- res,
- 429,
- { error: `too many active runs (max ${maxActiveRuns}); wait for one to finish` },
- cors,
- );
+ return { ok: false, status: 429, error: `too many active runs (max ${maxActiveRuns}); wait for one to finish` };
}
+ // Remember this run's shaping so chat-initiated runs (Telegram) can reuse
+ // the same presets + repo by supplying only a new goal.
+ lastRun = { env: { ...(body.env ?? {}) }, ...(repoDir ? { repoDir } : {}) };
+
// Live wiring: the observer streams transitions/attempts/outcomes; the
// tapping store streams lifecycle + runner-call events; `log` streams the
// engine's human-readable lines. All flow through the same hub channel.
@@ -447,9 +491,9 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
// Fire-and-forget: the run proceeds in the background and the client
// follows it over SSE. Errors (including cancellation) surface as a terminal
- // status message. The settle promise is tracked so a graceful shutdown can
- // wait for an aborted run to finish persisting its failure and cleaning up
- // its worktree.
+ // status message AND (best-effort) to the outbound notifier. The settle
+ // promise is tracked so a graceful shutdown can wait for an aborted run to
+ // finish persisting its failure and cleaning up its worktree.
const run = runGoalImpl(goal, runConfig, {
store: tappedStore,
sessionId,
@@ -461,12 +505,12 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
})
.then((result) => {
hub.publish(sessionId, "status", { phase: "done", result } satisfies RunStatusData);
+ notifySafe({ sessionId, goal, phase: "done", result });
})
.catch((err: unknown) => {
- hub.publish(sessionId, "status", {
- phase: "error",
- error: String((err as Error)?.message ?? err),
- } satisfies RunStatusData);
+ const error = String((err as Error)?.message ?? err);
+ hub.publish(sessionId, "status", { phase: "error", error } satisfies RunStatusData);
+ notifySafe({ sessionId, goal, phase: "error", error });
})
.finally(() => {
settle();
@@ -474,7 +518,17 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
});
activeRunPromises.add(run);
- send(res, 202, { sessionId }, cors);
+ return { ok: true, sessionId };
+ }
+
+ /** Best-effort outbound notification; a notifier error never affects a run. */
+ function notifySafe(info: RunFinishedInfo): void {
+ if (!notifier) return;
+ try {
+ notifier.runFinished(info);
+ } catch {
+ /* never let a notification failure surface into the run */
+ }
}
function streamRun(
@@ -665,5 +719,11 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer {
stop(): Promise {
return closeServer();
},
+ submitRun(body: StartRunBody): Promise {
+ return submitRun(body);
+ },
+ lastRunConfig(): { env: Record; repoDir?: string } | undefined {
+ return lastRun;
+ },
};
}
diff --git a/test/responsesRunner.test.ts b/test/responsesRunner.test.ts
new file mode 100644
index 0000000..230f821
--- /dev/null
+++ b/test/responsesRunner.test.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect } from "vitest";
+import {
+ ResponsesRunner,
+ extractResponsesText,
+} from "../src/runners/responsesRunner.js";
+import type { FetchLike } from "../src/runners/httpRunner.js";
+import type { RunnerProfile } from "../src/runners/agentRunner.js";
+
+const profile = (options: Record): RunnerProfile => ({
+ id: "p",
+ kind: "http-responses",
+ model: "gpt-5.5",
+ options,
+});
+
+/** A fake fetch that records the call and returns a scripted response. */
+function fakeFetch(
+ impl: (url: string, init: { headers: Record; body: string }) =>
+ | { ok: boolean; status: number; json?: unknown; text?: string },
+): { fetch: FetchLike; calls: Array<{ url: string; headers: Record; body: any }> } {
+ const calls: Array<{ url: string; headers: Record; body: any }> = [];
+ const fetch: FetchLike = async (url, init) => {
+ calls.push({ url, headers: init.headers, body: JSON.parse(init.body) });
+ const r = impl(url, init);
+ return {
+ ok: r.ok,
+ status: r.status,
+ json: async () => r.json,
+ text: async () => r.text ?? "",
+ };
+ };
+ return { fetch, calls };
+}
+
+/** A realistic Responses API success body (no output_text convenience field). */
+const outputBody = (text: string, usage?: unknown) => ({
+ output: [
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text }],
+ },
+ ],
+ ...(usage ? { usage } : {}),
+});
+
+describe("extractResponsesText", () => {
+ it("prefers the output_text convenience field", () => {
+ expect(extractResponsesText({ output_text: "hi", output: [] })).toBe("hi");
+ });
+
+ it("joins an output_text array", () => {
+ expect(extractResponsesText({ output_text: ["a", "b"] })).toBe("ab");
+ });
+
+ it("walks output[].content[] for output_text parts", () => {
+ expect(extractResponsesText(outputBody("from-content"))).toBe("from-content");
+ });
+
+ it("returns empty string for unexpected shapes", () => {
+ expect(extractResponsesText(null)).toBe("");
+ expect(extractResponsesText({ choices: [] })).toBe("");
+ });
+});
+
+describe("ResponsesRunner option validation", () => {
+ it("rejects a profile without a valid baseUrl", () => {
+ expect(() => new ResponsesRunner(profile({}), { fetch: fakeFetch(() => ({ ok: true, status: 200 })).fetch }))
+ .toThrow();
+ expect(() => new ResponsesRunner(profile({ baseUrl: "nope" }), { fetch: fakeFetch(() => ({ ok: true, status: 200 })).fetch }))
+ .toThrow();
+ });
+
+ it("rejects unknown options (strict schema)", () => {
+ expect(
+ () =>
+ new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1", bogus: 1 }), {
+ fetch: fakeFetch(() => ({ ok: true, status: 200 })).fetch,
+ }),
+ ).toThrow();
+ });
+});
+
+describe("ResponsesRunner request shaping", () => {
+ it("posts to /responses with input + instructions and the profile model", async () => {
+ const { fetch, calls } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("hello") }));
+ const runner = new ResponsesRunner(
+ profile({ baseUrl: "https://api.openai.com/v1", temperature: 0.3, maxOutputTokens: 256 }),
+ { fetch },
+ );
+ const res = await runner.run({ prompt: "do it", cwd: ".", system: "you are X" });
+
+ expect(res.text).toBe("hello");
+ expect(calls[0]?.url).toBe("https://api.openai.com/v1/responses");
+ expect(calls[0]?.body.model).toBe("gpt-5.5");
+ expect(calls[0]?.body.input).toBe("do it");
+ expect(calls[0]?.body.instructions).toBe("you are X");
+ expect(calls[0]?.body.temperature).toBe(0.3);
+ expect(calls[0]?.body.max_output_tokens).toBe(256);
+ });
+
+ it("sends reasoning.effort when configured", async () => {
+ const { fetch, calls } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("ok") }));
+ const runner = new ResponsesRunner(
+ profile({ baseUrl: "https://api.openai.com/v1", reasoningEffort: "high" }),
+ { fetch },
+ );
+ await runner.run({ prompt: "p", cwd: "." });
+ expect(calls[0]?.body.reasoning).toEqual({ effort: "high" });
+ });
+
+ it("reads the API key from the referenced env var (secret by reference)", async () => {
+ const { fetch, calls } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("ok") }));
+ const runner = new ResponsesRunner(
+ profile({ baseUrl: "https://api.openai.com/v1", apiKeyEnv: "MY_KEY", headers: { "x-org": "${MY_ORG}" } }),
+ { fetch, env: { MY_KEY: "secret-123", MY_ORG: "acme" } },
+ );
+ await runner.run({ prompt: "p", cwd: "." });
+ expect(calls[0]?.headers["authorization"]).toBe("Bearer secret-123");
+ expect(calls[0]?.headers["x-org"]).toBe("acme");
+ });
+
+ it("surfaces provider usage in meta for the cost ledger", async () => {
+ const { fetch } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("ok", { total_tokens: 99 }) }));
+ const runner = new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1" }), { fetch });
+ const res = await runner.run({ prompt: "p", cwd: "." });
+ expect(res.meta?.usage).toEqual({ total_tokens: 99 });
+ });
+});
+
+describe("ResponsesRunner quota + error handling", () => {
+ it("flags quotaExhausted on HTTP 429", async () => {
+ const { fetch } = fakeFetch(() => ({ ok: false, status: 429, text: "rate limit" }));
+ const runner = new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1" }), { fetch });
+ const res = await runner.run({ prompt: "p", cwd: "." });
+ expect(res.quotaExhausted).toBe(true);
+ expect(res.text).toBe("");
+ expect(res.meta?.status).toBe(429);
+ });
+
+ it("flags quotaExhausted when the error body matches the configured pattern", async () => {
+ const { fetch } = fakeFetch(() => ({ ok: false, status: 400, text: "insufficient_quota for org" }));
+ const runner = new ResponsesRunner(
+ profile({ baseUrl: "https://api.openai.com/v1", quota: { statusCodes: [], pattern: "insufficient_quota" } }),
+ { fetch },
+ );
+ const res = await runner.run({ prompt: "p", cwd: "." });
+ expect(res.quotaExhausted).toBe(true);
+ });
+
+ it("resolves transport failures to a result with diagnostics instead of throwing", async () => {
+ const fetch: FetchLike = async () => {
+ throw new Error("ECONNREFUSED");
+ };
+ const runner = new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1" }), { fetch });
+ const res = await runner.run({ prompt: "p", cwd: "." });
+ expect(res.text).toBe("");
+ expect(String(res.meta?.error)).toContain("ECONNREFUSED");
+ });
+});
diff --git a/test/telegram.test.ts b/test/telegram.test.ts
new file mode 100644
index 0000000..66ecc63
--- /dev/null
+++ b/test/telegram.test.ts
@@ -0,0 +1,193 @@
+import { describe, it, expect } from "vitest";
+import {
+ TelegramRelay,
+ formatFinalStatus,
+ createTelegramRelayFromEnv,
+ type TelegramFetch,
+ type RunSubmitter,
+} from "../src/notify/telegram.js";
+import type { SessionResult } from "../src/session.js";
+import type { StartRunBody } from "../src/server/server.js";
+
+interface TgUpdate {
+ update_id: number;
+ message?: { text?: string; chat?: { id?: number | string } };
+}
+
+/** A fake Telegram transport: scripts getUpdates batches, records sendMessage. */
+function fakeTelegram(batches: TgUpdate[][]) {
+ const sent: Array<{ chat_id: string; text: string }> = [];
+ const getUpdatesUrls: string[] = [];
+ let batch = 0;
+ const fetchImpl: TelegramFetch = async (url, init) => {
+ if (url.includes("/getUpdates")) {
+ getUpdatesUrls.push(url);
+ const result = batches[batch] ?? [];
+ batch += 1;
+ return { ok: true, status: 200, json: async () => ({ ok: true, result }), text: async () => "" };
+ }
+ if (url.includes("/sendMessage")) {
+ sent.push(JSON.parse(init?.body ?? "{}"));
+ return { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "" };
+ }
+ return { ok: false, status: 404, json: async () => ({}), text: async () => "not found" };
+ };
+ return { fetchImpl, sent, getUpdatesUrls };
+}
+
+function fakeSubmitter(last?: { env: Record; repoDir?: string }) {
+ const calls: StartRunBody[] = [];
+ const submitter: RunSubmitter & { calls: StartRunBody[] } = {
+ calls,
+ submitRun: async (body) => {
+ calls.push(body);
+ return { ok: true, sessionId: "sess-1" };
+ },
+ lastRunConfig: () => last,
+ };
+ return submitter;
+}
+
+const sessionResult = (over: Partial): SessionResult =>
+ ({
+ goal: "g",
+ plan: {} as never,
+ results: [],
+ green: [],
+ unverified: [],
+ needsHuman: [],
+ skipped: [],
+ allVerified: true,
+ ...over,
+ }) as SessionResult;
+
+describe("formatFinalStatus", () => {
+ it("formats a completed run with counts", () => {
+ const msg = formatFinalStatus({
+ sessionId: "abc",
+ goal: "Add /healthz",
+ phase: "done",
+ result: sessionResult({ green: ["t1", "t2"], allVerified: true }),
+ });
+ expect(msg).toContain("✅ Loopwright: completed");
+ expect(msg).toContain("“Add /healthz”");
+ expect(msg).toContain("green 2");
+ expect(msg).toContain("session abc");
+ });
+
+ it("flags needs-attention and includes a PR link when present", () => {
+ const msg = formatFinalStatus({
+ sessionId: "abc",
+ goal: "g",
+ phase: "done",
+ result: sessionResult({
+ needsHuman: ["t3"],
+ publish: { pushed: true, remote: "origin", branch: "b", pushBranch: "b", pr: { created: true, url: "https://gh/pr/1" } } as never,
+ }),
+ });
+ expect(msg).toContain("⚠️ Loopwright: needs attention");
+ expect(msg).toContain("PR: https://gh/pr/1");
+ expect(msg).toContain("Reply with a new goal");
+ });
+
+ it("formats a failed run", () => {
+ const msg = formatFinalStatus({ sessionId: "abc", goal: "g", phase: "error", error: "boom" });
+ expect(msg).toContain("❌ Loopwright run failed");
+ expect(msg).toContain("boom");
+ });
+});
+
+describe("TelegramRelay.runFinished", () => {
+ it("sends the final status to the configured chat", async () => {
+ const tg = fakeTelegram([]);
+ const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl });
+ relay.runFinished({ sessionId: "s", goal: "do it", phase: "done", result: sessionResult({}) });
+ // runFinished fires the send without awaiting; let the microtask settle.
+ await new Promise((r) => setTimeout(r, 0));
+ expect(tg.sent).toHaveLength(1);
+ expect(tg.sent[0]?.chat_id).toBe("42");
+ expect(tg.sent[0]?.text).toContain("Loopwright");
+ });
+});
+
+describe("TelegramRelay control channel", () => {
+ it("runs a message from the allowlisted chat as a new goal, reusing last config", async () => {
+ const tg = fakeTelegram([]);
+ const submitter = fakeSubmitter({ env: { LOOPWRIGHT_RUNNERS: "[]" }, repoDir: "/repo" });
+ const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false });
+ relay.attach(submitter);
+
+ await relay.handleUpdate({ update_id: 1, message: { text: "Fix the bug", chat: { id: 42 } } });
+
+ expect(submitter.calls).toHaveLength(1);
+ expect(submitter.calls[0]).toMatchObject({ goal: "Fix the bug", repoDir: "/repo", env: { LOOPWRIGHT_RUNNERS: "[]" } });
+ expect(tg.sent[0]?.text).toContain("▶️ Starting run");
+ expect(tg.sent[0]?.text).toContain("sess-1");
+ });
+
+ it("ignores messages from any other chat", async () => {
+ const tg = fakeTelegram([]);
+ const submitter = fakeSubmitter({ env: {}, repoDir: "/repo" });
+ const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false });
+ relay.attach(submitter);
+
+ await relay.handleUpdate({ update_id: 1, message: { text: "do it", chat: { id: 999 } } });
+ expect(submitter.calls).toHaveLength(0);
+ expect(tg.sent).toHaveLength(0);
+ });
+
+ it("replies with help and does not start a run for /help", async () => {
+ const tg = fakeTelegram([]);
+ const submitter = fakeSubmitter({ env: {}, repoDir: "/repo" });
+ const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false });
+ relay.attach(submitter);
+
+ await relay.handleUpdate({ update_id: 1, message: { text: "/help", chat: { id: 42 } } });
+ expect(submitter.calls).toHaveLength(0);
+ expect(tg.sent[0]?.text).toContain("Loopwright relay");
+ });
+
+ it("asks the user to start from desktop when there is no run config yet", async () => {
+ const tg = fakeTelegram([]);
+ const submitter = fakeSubmitter(undefined);
+ const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false });
+ relay.attach(submitter);
+
+ await relay.handleUpdate({ update_id: 1, message: { text: "do something", chat: { id: 42 } } });
+ expect(submitter.calls).toHaveLength(0);
+ expect(tg.sent[0]?.text).toContain("don't have a run configuration");
+ });
+});
+
+describe("TelegramRelay polling", () => {
+ it("skips the initial backlog, then handles later messages, advancing the offset", async () => {
+ // First poll returns a backlog message (must be skipped); second returns a new one.
+ const tg = fakeTelegram([
+ [{ update_id: 5, message: { text: "old", chat: { id: 42 } } }],
+ [{ update_id: 6, message: { text: "new goal", chat: { id: 42 } } }],
+ ]);
+ const submitter = fakeSubmitter({ env: {}, repoDir: "/repo" });
+ const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl });
+ relay.attach(submitter);
+
+ await relay.pollOnce(); // backlog: advances offset, no handling
+ expect(submitter.calls).toHaveLength(0);
+
+ await relay.pollOnce(); // handles "new goal"
+ expect(submitter.calls).toHaveLength(1);
+ expect(submitter.calls[0]?.goal).toBe("new goal");
+
+ // The second getUpdates must carry offset=6 (5 + 1) to ack the backlog.
+ expect(tg.getUpdatesUrls[1]).toContain("offset=6");
+ });
+});
+
+describe("createTelegramRelayFromEnv", () => {
+ it("returns undefined when not configured", () => {
+ expect(createTelegramRelayFromEnv({})).toBeUndefined();
+ });
+ it("builds a relay from TELEGRAM_* secret names", () => {
+ const relay = createTelegramRelayFromEnv({ TELEGRAM_BOT_TOKEN: "T", TELEGRAM_CHAT_ID: "42" });
+ expect(relay).toBeInstanceOf(TelegramRelay);
+ });
+});