/** * Sentient Daemon — Tier-1 autonomous loop sidecar. * * Runs as a detached Node.js process and executes `runTick()` every 5 * minutes. Mirrors the gc/daemon.ts sidecar pattern (ADR-047) and honours * the worktree protocol — all state lives under the project's `.cleo/`. * * Scoped IN (this module): * - Tier-1 execution of unblocked tasks via `cleo orchestrate spawn` * - Kill-switch with re-check at every tick checkpoint * - Advisory locking via an OS-level lockfile (two daemons cannot coexist) * - Stuck detection + self-pause on stuck-rate threshold * - fs.watch-based fast kill propagation * - Studio supervision: daemon spawns and restarts the Studio web server * child process (T1683). Enabled by default; disable via * `daemon.superviseStudio = false` in `~/.cleo/config.json`. * * Scoped OUT (separate epics): * - Tier-2 proposal queue (`cleo propose` / status='proposed' generation) * - Tier-3 sandbox auto-merge (requires agent-in-container infra) * - Ed25519 signing of receipts (handled by Agent B2 llmtxt/identity wiring) * * @see ADR-054 — Sentient Loop Tier-1 * @task T946 * @task T1683 — Studio supervision + SDK control API */ import { type FileHandle } from 'node:fs/promises'; import { type SentientState } from './state.js'; /** Relative subpath under a project root where sentient state lives. */ export declare const SENTIENT_STATE_FILE: ".cleo/sentient-state.json"; /** Relative subpath for the daemon lockfile. */ export declare const SENTIENT_LOCK_FILE: ".cleo/sentient.lock"; /** Cron expression: every 5 minutes (Tier-1 tick). */ export declare const SENTIENT_CRON_EXPR = "*/5 * * * *"; /** * Cron expression: every 2 hours (Tier-2 propose tick). * * Separate from the Tier-1 cron to avoid proposal flooding. * Only fires when `tier2Enabled = true` in sentient-state.json. * * @task T1008 */ export declare const SENTIENT_PROPOSE_CRON_EXPR = "0 */2 * * *"; /** * Cron expression: nightly at 02:00 local time (cross-project hygiene loop). * * Runs after the usual low-traffic window. Configurable via * `CLEO_HYGIENE_CRON` environment variable override. * * @task T1637 */ export declare const SENTIENT_HYGIENE_CRON_EXPR: string; /** Subdirectory for daemon logs. */ export declare const SENTIENT_LOG_DIR: ".cleo/logs"; /** Log filename (stdout). */ export declare const SENTIENT_LOG: "sentient.log"; /** Log filename (stderr). */ export declare const SENTIENT_ERR: "sentient.err"; /** * Default port for the Cleo Studio web server. * Matches the `dev` script in packages/studio/package.json. */ export declare const STUDIO_DEFAULT_PORT = 3456; /** * Initial restart delay (ms) for Studio crash-restart backoff. * Doubles on each consecutive crash up to {@link STUDIO_MAX_RESTART_DELAY_MS}. */ export declare const STUDIO_INITIAL_RESTART_DELAY_MS = 1000; /** * Maximum restart delay (ms) — caps the exponential backoff to 30 seconds. * Prevents infinite tight-loop crashes from consuming resources. */ export declare const STUDIO_MAX_RESTART_DELAY_MS = 30000; /** * Options for the Studio supervision loop. * * @public */ export interface StudioSupervisorOptions { /** * Absolute path to the Studio package root. * The supervisor runs `node build/index.js` inside this directory. * Defaults to locating the `@cleocode/studio` package relative to this file. */ studioPackageDir?: string; /** * Port the Studio server should listen on. * @default 3456 */ port?: number; /** * Initial backoff delay (ms) after the first crash. * @default 1000 */ initialRestartDelayMs?: number; /** * Maximum backoff delay (ms) after repeated crashes. * @default 30000 */ maxRestartDelayMs?: number; } /** * Studio status returned by the supervisor. * * - `'running'` — Studio child process is active. * - `'stopped'` — Supervisor was stopped cleanly. * - `'crashed'` — Studio child exited unexpectedly; restart pending. * - `'disabled'` — Studio supervision is disabled in config. * - `'not-available'` — Studio package is not installed (graceful degrade, * T1684 hotfix). Daemon continues without Studio. * * @public */ export type StudioStatus = 'running' | 'stopped' | 'crashed' | 'disabled' | 'not-available'; /** * StudioSupervisor manages the Cleo Studio web server as a child process * of the sentient daemon. * * One daemon = sentient ticks + Studio HTTP server (T1683). * * Lifecycle: * - `start()` — spawn Studio; attach crash handler * - On crash: wait (with exponential backoff), then respawn * - `stop()` — send SIGTERM with 10 s grace period, then SIGKILL * * @public */ export declare class StudioSupervisor { #private; /** * @param opts - Optional configuration overrides. */ constructor(opts?: StudioSupervisorOptions); /** * Current status of the Studio child process. */ get status(): StudioStatus; /** * PID of the current Studio child process, or null if not running. */ get pid(): number | null; /** * Start the Studio server and enable crash-restart supervision. * * Safe to call when already running — a no-op in that case. */ start(): void; /** * Stop the Studio server gracefully. * * Sends SIGTERM to the Studio child, waits up to 10 seconds for it to * exit, then sends SIGKILL if it has not exited. * * @returns Promise that resolves when the child has exited. */ stop(): Promise; } /** * Read the `daemon.superviseStudio` flag from `~/.cleo/config.json`. * * Returns `true` (default on) when the flag is absent or when the config * file cannot be read. Callers can explicitly set `false` to disable: * * ```json * { "daemon": { "superviseStudio": false } } * ``` * * @param globalConfigPath - Absolute path to `~/.cleo/config.json`. * @returns Whether the daemon should supervise Studio. */ export declare function readSuperviseStudioConfig(globalConfigPath: string): Promise; /** * Resolved curator configuration as read from `~/.cleo/config.json`. * * @public */ export interface CuratorConfig { /** * Master enable flag. When `false` (the default), the curator cron is * NEVER scheduled — `cleo sentient kill` cannot affect what isn't running. * * @defaultValue `false` */ enabled: boolean; /** * Interval (hours) between curator ticks. Default: 168 (every 7 days). * * The cron expression is rounded to the nearest whole-hour cadence so * fractional intervals (e.g. `0.5`) are clamped UP to `1`. * * @defaultValue 168 */ runEveryHours: number; /** * Days of no activity after which an `active` row flips to `stale`. * * @defaultValue 30 */ staleAfterDays: number; /** * Days of no activity after which a row is archived to disk. * * @defaultValue 90 */ archiveAfterDays: number; } /** Default curator configuration when none is present in the config file. */ export declare const DEFAULT_CURATOR_CONFIG: CuratorConfig; /** * Read `daemon.curator.*` from `~/.cleo/config.json` (T9683). * * @remarks * Schema (all keys optional, defaults applied per-field): * * ```json * { * "daemon": { * "curator": { * "enabled": false, * "runEveryHours": 168, * "staleAfterDays": 30, * "archiveAfterDays": 90 * } * } * } * ``` * * Any field that is missing, malformed, or out-of-range falls back to the * default in {@link DEFAULT_CURATOR_CONFIG}. The config file as a whole may * be absent — that case also yields the defaults (enabled=false). * * @param globalConfigPath - Absolute path to `~/.cleo/config.json`. * @returns The resolved curator config (always populated). */ export declare function readCuratorConfig(globalConfigPath: string): Promise; /** * Convert an hourly interval into a node-cron expression. * * @remarks * - For intervals < 24 hours, emits `0 *\/ * * *` (every N hours on the hour). * - For intervals that are an exact multiple of 24, emits `0 0 *\/ * *` * (every N days at midnight UTC). * - For intervals that don't divide cleanly, falls back to `0 *\/ * * *` * capped at 23 hours — node-cron does not natively support multi-day * periodicity except via day-of-month, which has the usual 28-31 footgun. * * @param runEveryHours - Interval as configured (must be >= 1). * @returns A valid 5-field cron expression. */ export declare function curatorCronExpression(runEveryHours: number): string; /** Handle to an active advisory lock. */ export interface LockHandle { /** Absolute path to the lockfile. */ path: string; /** Underlying file handle held exclusively by this process. */ handle: FileHandle; } /** * Acquire an exclusive advisory lock on the sentient lockfile. * * Uses `fs.open` with `O_CREAT | O_EXCL` semantics — if the file already * exists AND its recorded pid is alive, acquisition fails. Stale lockfiles * (pid dead) are reclaimed automatically. * * @param lockPath - Absolute path to `.cleo/sentient.lock` * @returns Lock handle, or null if lock is held by a live process */ export declare function acquireLock(lockPath: string): Promise; /** * Release an advisory lock acquired via {@link acquireLock}. * Does NOT remove the lockfile — the pid inside is a useful diagnostic. */ export declare function releaseLock(lock: LockHandle): Promise; /** * Options for {@link bootstrapDaemon}. * * @public */ export interface BootstrapDaemonOptions { /** * Whether the daemon should supervise the Cleo Studio web server. * * When `true` (default), Studio is spawned as a child process inside the * daemon and restarted on crash. Set to `false` to run Studio independently. * Overrides the `daemon.superviseStudio` flag in `~/.cleo/config.json` when * supplied explicitly. */ superviseStudio?: boolean; /** * Path to the global CLEO config file. * Defaults to `~/.cleo/config.json` (resolved via getCleoHome()). * Used to read `daemon.superviseStudio` when `opts.superviseStudio` is not * supplied explicitly. */ globalConfigPath?: string; /** * Studio supervisor options (port, restart delays, package dir). * Only used when Studio supervision is enabled. */ studioOptions?: StudioSupervisorOptions; /** * Scope the task picker to tasks within this Saga (member Epics + their * children). When set, the daemon operates as a walk-away / headless * autopilot scoped to a single Saga. Read from `CLEO_SENTIENT_SAGA` env * var in the daemon-entry process when not supplied directly. * * @task T11497 E5-HEADLESS AC1 + AC3 */ scopeSagaId?: string; /** * Scope the task picker to tasks directly under this Epic. Overrides * `scopeSagaId` when both are set. * * @task T11497 E5-HEADLESS AC1 + AC3 */ scopeEpicId?: string; } /** * Bootstrap the sentient daemon process. * * Steps: * 1. Acquire advisory lock (fail fast if another daemon is running) * 2. Persist our pid + startedAt to state.json * 3. Optionally start Studio supervision (T1683) * 4. Watch state.json for killSwitch changes (fast propagation) * 5. Register a SIGTERM handler for graceful shutdown (cascades to Studio) * 6. Schedule cron with noOverlap so long ticks don't stack * * @param projectRoot - Absolute path to the project (contains `.cleo/`) * @param opts - Optional overrides for Studio supervision and config path. */ export declare function bootstrapDaemon(projectRoot: string, opts?: BootstrapDaemonOptions): Promise; /** Outcome of {@link spawnSentientDaemon}. */ export interface SpawnDaemonResult { /** PID of the spawned daemon. */ pid: number; /** Absolute path to the .cleo/sentient-state.json file. */ statePath: string; /** Absolute path to the log file. */ logPath: string; } /** * Spawn the sentient daemon as a detached background process. * * All three T751 §2.2 requirements: * 1. `detached: true` — process-group leader survives parent exit * 2. File-based stdio — no TTY inheritance * 3. `child.unref()` — parent CLI returns immediately * * @param projectRoot - Absolute path to the project root (contains `.cleo/`) * @returns PID + log paths */ export declare function spawnSentientDaemon(projectRoot: string): Promise; /** Outcome of {@link stopSentientDaemon}. */ export interface StopDaemonResult { /** Whether the stop signal was delivered. */ stopped: boolean; /** Last known pid; null if no pid was recorded. */ pid: number | null; /** Human-readable reason. */ reason: string; } /** * Stop the sentient daemon. * * Flips killSwitch=true FIRST (so an in-flight tick notices on its next * checkpoint re-read), then sends SIGTERM. This gives the daemon a fast, * graceful shutdown path even during a long-running spawn. * * @param projectRoot - Absolute path to the project root * @param reason - Optional reason stored on state file for diagnostics * @returns Stop result */ export declare function stopSentientDaemon(projectRoot: string, reason?: string): Promise; /** * Clear the kill switch so the cron schedule resumes executing ticks. * * Does NOT restart the daemon process — that is the caller's responsibility * via `cleo sentient start` if the process itself exited. * * @param projectRoot - Absolute path to the project root */ export declare function resumeSentientDaemon(projectRoot: string): Promise; /** Status snapshot returned by {@link getSentientDaemonStatus}. */ export interface SentientStatus { /** Whether the pid on file is currently alive. */ running: boolean; /** Recorded pid (null when never started or cleared on stop). */ pid: number | null; /** ISO-8601 timestamp of last start. */ startedAt: string | null; /** ISO-8601 timestamp of the last completed tick. */ lastTickAt: string | null; /** * ISO-8601 timestamp of the last cron-callback dispatch — written BEFORE * `safeRunTick` runs. Use the delta `lastTickAt - lastCronFiredAt` to * detect tick hangs: when the heartbeat advances but the tick stamp does * not, a tick is stuck mid-execution. * * @task T-DAEMON-LASTTICKAT (T9320 follow-up) */ lastCronFiredAt: string | null; /** Kill-switch state. */ killSwitch: boolean; /** Reason supplied with the last kill. */ killSwitchReason: string | null; /** Rolling stats. */ stats: SentientState['stats']; /** Number of currently-stuck tasks. */ stuckCount: number; /** Currently active task id (set mid-tick). */ activeTaskId: string | null; /** * T1637: ISO-8601 timestamp of the last cross-project hygiene loop run. * Null when the hygiene loop has never executed. */ hygieneLastRunAt: string | null; /** * T1637: One-line summary of the last hygiene run (for `cleo daemon status`). */ hygieneSummary: string | null; /** * T1637: Summary counts from the last hygiene loop. */ hygieneStats: SentientState['hygieneStats']; /** * T1683: Whether the daemon is configured to supervise the Studio web server. * Read from `daemon.superviseStudio` in `~/.cleo/config.json` (default: true). */ supervisesStudio: boolean; /** * T1683: Current status of the Studio child process. * `'disabled'` when Studio supervision is off. */ studioStatus: StudioStatus; } /** * Return a diagnostic snapshot for `cleo sentient status`. * * Includes T1683 Studio supervision fields: `supervisesStudio` and `studioStatus`. * Studio status is determined from the config file (not live process state, since * the supervisor runs inside the daemon process, not the status caller). * * @param projectRoot - Absolute path to the project root */ export declare function getSentientDaemonStatus(projectRoot: string): Promise; /** * Size-based wall-clock budget thresholds in milliseconds. * * Workers active beyond 2× their size budget are considered runaway and * eligible for abort. Budget is sized around human-comparable work units: * `small` ~ half an hour, `medium` ~ two hours, `large` ~ four hours. * * @task T1658 */ export declare const WORKER_BUDGET_MS: Record; /** * Multiplier applied to the size budget before flagging a worker as runaway. * * At 2× a worker has consumed twice the expected wall-clock time for its size. * The sentient monitor emits a warning at 1× (budget) and aborts at 2×. */ export declare const RUNAWAY_BUDGET_MULTIPLIER = 2; /** A single active-worker row returned by {@link monitorWorkers}. */ export interface WorkerMonitorRow { /** Task ID of the worker. */ taskId: string; /** Task title for display. */ title: string; /** Task size (small | medium | large | unknown). */ size: string; /** ISO-8601 timestamp when the task transitioned to in_progress. */ startedAt: string; /** Elapsed wall-clock time in milliseconds. */ elapsedMs: number; /** Expected budget in milliseconds based on size. */ budgetMs: number; /** True when elapsed > budget (warn). */ overBudget: boolean; /** True when elapsed > RUNAWAY_BUDGET_MULTIPLIER × budget (abort). */ runaway: boolean; } /** * Scan for in-progress tasks and evaluate their wall-clock elapsed time * against their size budget. * * Runaway detection does NOT automatically abort workers — the caller * (`cleo sentient monitor`) decides what action to take. This function is * read-only and safe to call at any frequency. * * The task store is accessed via the CLEO CLI (`cleo list --status in_progress`) * rather than direct SQLite access to respect ADR-013 DB-separation rules. * * @param projectRoot - Absolute path to the project root. * @returns Array of monitor rows for all in-progress tasks. * * @task T1658 */ export declare function monitorWorkers(projectRoot: string): Promise; //# sourceMappingURL=daemon.d.ts.map