Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpencodeClient["session"]["prompt"]>[0]["model"]
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<string, unknown>) {
if (args.format === "json") {
process.stdout.write(
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/run/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -717,6 +719,13 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
"opencode.demo": input.demo,
},
async () => {
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,
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -376,6 +377,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {

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) {
Expand Down Expand Up @@ -816,6 +828,25 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
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,
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/config/psychosis-detector.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Info>
167 changes: 167 additions & 0 deletions packages/opencode/src/effect/instance-heartbeat.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<CheckResult> {
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),
}
}
Loading