diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b80a2389ef24..b9bce6dd446e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,6 +27,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceRef } from "@/effect/instance-ref" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" +import { initHeartbeat, checkHeartbeat } from "@/effect/instance-heartbeat" const runtimeTask = import("./run/runtime") type ModelInput = Parameters[0]["model"] @@ -248,6 +249,7 @@ export const RunCommand = effectCmd({ const flags = yield* RuntimeFlags.Service const localInstance = yield* InstanceRef yield* Effect.promise(async () => { + await initHeartbeat() const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) const die = (message: string): never => { @@ -615,6 +617,11 @@ export const RunCommand = effectCmd({ } const sessionID = sess.id + const heart = await checkHeartbeat().catch(() => undefined) + if (heart && heart.severity !== "fine") { + UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, heart.message) + } + function emit(type: string, data: Record) { if (args.format === "json") { process.stdout.write( diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index c9450d4602db..eaf1dcebb4d0 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -21,6 +21,8 @@ import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" import type { RunInput, RunPrompt, RunProvider } from "./types" +import { UI } from "../../ui" +import { initHeartbeat, checkHeartbeat } from "@/effect/instance-heartbeat" /** @internal Exported for testing */ export { pickVariant, resolveVariant } from "./variant.shared" @@ -717,6 +719,13 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise { + await initHeartbeat() + + const heart = await checkHeartbeat().catch(() => undefined) + if (heart && heart.severity !== "fine") { + UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, heart.message) + } + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: input.fetch, diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 530ba3ff239a..46cbb07c228d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -61,6 +61,7 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { initHeartbeat, checkHeartbeat } from "@/effect/instance-heartbeat" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" @@ -376,6 +377,17 @@ function App(props: { onSnapshot?: () => Promise }) { const args = useArgs() onMount(() => { + initHeartbeat().then(() => { + checkHeartbeat().then((heart) => { + if (heart.severity !== "fine" && kv.get("psychosis_detector_enabled", false)) { + toast.show({ + variant: heart.severity === "worried" || heart.severity === "urgent" ? "error" : "warning", + message: heart.message, + duration: 8000, + }) + } + }).catch(() => {}) + }) batch(() => { if (args.agent) local.agent.set(args.agent) if (args.model) { @@ -816,6 +828,25 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + name: "app.toggle.psychosis_detector", + title: kv.get("psychosis_detector_enabled", false) + ? "Disable AI psychosis detector" + : "Enable AI psychosis detector", + category: "System", + run: () => { + const next = !kv.get("psychosis_detector_enabled", false) + kv.set("psychosis_detector_enabled", next) + toast.show({ + variant: "info", + message: next + ? "AI psychosis detector enabled. Will warn about excessive usage." + : "AI psychosis detector disabled.", + duration: 3000, + }) + dialog.clear() + }, + }, ].map((command) => ({ namespace: "palette", ...command, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 307b02ca4d9f..08a98371eab6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -35,6 +35,7 @@ import { ConfigModelID } from "./model-id" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPermission } from "./permission" +import { ConfigPsychosisDetector } from "./psychosis-detector" import { ConfigPlugin } from "./plugin" import { ConfigProvider } from "./provider" import { ConfigReference } from "./reference" @@ -303,6 +304,10 @@ export const Info = Schema.Struct({ }), }), ), + psychosis_detector: Schema.optional(ConfigPsychosisDetector.Info).annotate({ + description: + "AI psychosis detector — warns when too many instances run for too long", + }), }).annotate({ identifier: "Config" }) // Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition diff --git a/packages/opencode/src/config/psychosis-detector.ts b/packages/opencode/src/config/psychosis-detector.ts new file mode 100644 index 000000000000..88551741014e --- /dev/null +++ b/packages/opencode/src/config/psychosis-detector.ts @@ -0,0 +1,20 @@ +export * as ConfigPsychosisDetector from "./psychosis-detector" + +import { Schema } from "effect" +import { PositiveInt } from "@opencode-ai/core/schema" + +export const Info = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean).annotate({ + description: + "Warn when too many opencode instances are running for too long", + }), + max_instances: Schema.optional(PositiveInt).annotate({ + description: + "Number of running instances before warnings begin (default: 3)", + }), + max_hours: Schema.optional(PositiveInt).annotate({ + description: + "Hours of continuous runtime before warnings begin (default: 12)", + }), +}).annotate({ identifier: "PsychosisDetectorConfig" }) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/effect/instance-heartbeat.ts b/packages/opencode/src/effect/instance-heartbeat.ts new file mode 100644 index 000000000000..8fa2938b359e --- /dev/null +++ b/packages/opencode/src/effect/instance-heartbeat.ts @@ -0,0 +1,167 @@ +import path from "path" +import fs from "fs/promises" +import os from "os" +import { Global } from "@opencode-ai/core/global" +import { ensureRunID } from "@opencode-ai/core/util/opencode-process" + +const HEARTBEAT_DIR = path.join(Global.Path.data, "instances") + +const STALE_MS = 48 * 60 * 60 * 1000 + +type HeartbeatData = { + pid: number + started_at: number + directory: string + hostname: string +} + +let heartbeatFile: string | null = null + +export const Severity = { + Fine: "fine", + Mild: "mild", + Concerned: "concerned", + Worried: "worried", + Urgent: "urgent", +} as const + +export type Severity = (typeof Severity)[keyof typeof Severity] + +export type CheckResult = { + severity: Severity + liveCount: number + myAgeHours: number + oldestAgeHours: number + message: string +} + +export async function initHeartbeat(): Promise { + const id = ensureRunID() + heartbeatFile = path.join(HEARTBEAT_DIR, `${id}.json`) + + const data: HeartbeatData = { + pid: process.pid, + started_at: Date.now(), + directory: process.cwd(), + hostname: os.hostname(), + } + + await fs.mkdir(HEARTBEAT_DIR, { recursive: true }) + await fs.writeFile(heartbeatFile, JSON.stringify(data)) + + const cleanup = () => cleanupHeartbeat() + process.once("exit", cleanup) + process.once("SIGTERM", () => { + cleanup() + process.exit() + }) +} + +export function cleanupHeartbeat(): void { + if (heartbeatFile) { + fs.unlink(heartbeatFile).catch(() => {}) + heartbeatFile = null + } +} + +function severityMessage( + severity: Severity, + liveCount: number, + hours: number, +): string { + switch (severity) { + case Severity.Mild: + if (liveCount >= 3) { + return `You have ${liveCount} instances of opencode running. Everything okay?` + } + return `You've been running opencode for ${Math.round(hours)} hours. Might be time for a break.` + case Severity.Concerned: + if (liveCount >= 5) { + return `That's ${liveCount} instances of opencode running. You might want to step away for a bit.` + } + return `You've been at this for ${Math.round(hours)} hours. Just a friendly check-in.` + case Severity.Worried: + if (liveCount >= 8) { + return `We're genuinely concerned. You have ${liveCount} instances running and it's been ${Math.round(hours)} hours. Please take a break.` + } + return `This is getting concerning. ${Math.round(hours)} hours straight. The code will still be there tomorrow.` + case Severity.Urgent: + return `This is a wellness check. You have ${liveCount} instances of opencode running and you've been going for ${Math.round(hours)} hours. We strongly recommend you step away and talk to someone.` + default: + return "" + } +} + +export async function checkHeartbeat( + maxInstances: number = 3, + maxHours: number = 12, +): Promise { + let files: string[] + try { + files = await fs.readdir(HEARTBEAT_DIR) + } catch { + return { + severity: Severity.Fine, + liveCount: 0, + myAgeHours: 0, + oldestAgeHours: 0, + message: "", + } + } + + const now = Date.now() + const heartbeats: HeartbeatData[] = [] + const myID = ensureRunID() + let myAge = 0 + + for (const file of files) { + if (!file.endsWith(".json")) continue + + try { + const content = await fs.readFile(path.join(HEARTBEAT_DIR, file), "utf-8") + const data: HeartbeatData = JSON.parse(content) + + if (now - data.started_at > STALE_MS) continue + + heartbeats.push(data) + + const id = file.replace(".json", "") + if (id === myID) { + myAge = now - data.started_at + } + } catch { + continue + } + } + + if (heartbeats.length === 0) { + return { + severity: Severity.Fine, + liveCount: 0, + myAgeHours: 0, + oldestAgeHours: 0, + message: "", + } + } + + const oldestAge = Math.max(...heartbeats.map((h) => now - h.started_at)) + const myHours = myAge / (1000 * 60 * 60) + const oldestHours = oldestAge / (1000 * 60 * 60) + const count = heartbeats.length + + const severity = ((): Severity => { + if (count >= 10 || oldestHours >= maxHours * 4) return Severity.Urgent + if (count >= 8 || oldestHours >= maxHours * 2) return Severity.Worried + if (count >= 5 || oldestHours >= maxHours) return Severity.Concerned + if (count >= maxInstances || myHours >= maxHours) return Severity.Mild + return Severity.Fine + })() + + return { + severity, + liveCount: count, + myAgeHours: myHours, + oldestAgeHours: oldestHours, + message: severityMessage(severity, count, oldestHours), + } +}