UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

172 lines (153 loc) 7.35 kB
/** * # cli/caller — the ONE Caller resolver: env-first / TTY-second, fail-closed to non-interactive * * `resolveCaller()` decides **who is invoking** `kestrel` — an automated **agent** (a coding-agent * harness, CI, a pipe) or a **human** — and whether the invocation is eligible for the interactive * session (a **confident human only**). It is the detector ADR-0035 §(a)/§(b) fixes and the oversight * contract (`docs/design/oversight-protocol-contract.md` §3.1) types as {@link Caller}. * * ## The resolution ladder (ADR-0035 §b — the load-bearing order) * 1. an explicit render flag (`--json` / `--format` / `--agent`) **wins** → non-interactive; * 2. else a detected **agent env** → `agent`, **never** interactive (env beats a real TTY: an agent * can hold a PTY, so `isatty` is true — the env is the truth, the TTY is not); * 3. else **CI** → `agent`, never interactive; * 4. else both stdin+stdout are real TTYs and no agent env / CI / flag → a **confident human** → * interactive-eligible; * 5. else (a piped / redirected human) → `human`, non-interactive (static text). * * **UNDER ANY AMBIGUITY, RESOLVE NON-INTERACTIVE.** The asymmetry is the whole argument (PRD §6): a * wrongly-launched TUI **blocks an automated harness forever** — the worst failure in the system; * guessing "agent" wrong merely gives a human plain text they can override with a flag. `interactive` * is therefore `true` only when *every* condition for a confident human holds. * * Pure + deterministic: reads its `flags` / `env` / `streams` arguments, never `process` directly * (except the thin {@link resolveCallerFromProcess} wrapper), so the whole detection matrix is * unit-testable without a terminal. Node-light (no heavy imports): safe on the CLI hot path. */ import type { GlobalFlags } from "./context.ts"; /** Who is invoking (oversight contract §3.1 `CallerKind`). */ export type CallerKind = "agent" | "human"; export const CALLER_KINDS = ["agent", "human"] as const satisfies readonly CallerKind[]; /** How the Caller was decided — auditable, never a guess we hide (oversight contract §3.1). */ export type CallerDetection = "flag" | "env" | "ci" | "tty"; export const CALLER_DETECTIONS = ["flag", "env", "ci", "tty"] as const satisfies readonly CallerDetection[]; /** * The resolved Caller (oversight contract §3.1). The single source read by the bare-invocation * orientation (and, when the platform lands, by platform-bound telemetry) so rendering and analytics * can never disagree about who is calling. * * FAIL-CLOSED: under ANY ambiguity `interactive` is `false`. */ export interface Caller { readonly kind: CallerKind; /** The detected harness / CI system, when known (e.g. `"CLAUDECODE"`, `"CI"`). `null` ⇒ unknown, * never invented. */ readonly harness: string | null; readonly detectedBy: CallerDetection; /** Interactive session eligible: a CONFIDENT human only — both TTYs, no agent env, no CI, no flag. */ readonly interactive: boolean; } /** * The agent env-var table (ADR-0035 §a, PRD §0.2). An agent harness sets one of these; they are * checked BEFORE `isatty` because an agent can hold a PTY (where `isatty` is true) and the env is the * ground truth the TTY would lie about. `CODEX_*` is a PREFIX (any `CODEX_…` var). `KESTREL_AGENT` is * this repo's own pre-existing agent signal (`src/cli/context.ts` `resolveOutputCtx`), folded in so * the two resolvers agree on it; every other marker is from the ADR table verbatim. */ export const AGENT_ENV_MARKERS = [ "CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CURSOR_AGENT", "GEMINI_CLI", "OPENCODE", "AI_AGENT", "AGENT", "KESTREL_AGENT", ] as const; /** The `CODEX_*` prefix (ADR table) — any environment variable whose name starts with this. */ export const AGENT_ENV_PREFIX = "CODEX_" as const; /** CI markers. `CI` is canonical (ADR §a); the others are this repo's existing set (`resolveOutputCtx`). */ export const CI_MARKERS = ["CI", "GITHUB_ACTIONS", "BUILDKITE", "GITLAB_CI"] as const; interface Env { readonly [k: string]: string | undefined; } interface Streams { readonly stdinTTY: boolean; readonly stdoutTTY: boolean; } /** A var counts as SET when present and non-empty — a bare `FOO=` never trips detection. */ function isSet(v: string | undefined): boolean { return v !== undefined && v !== ""; } /** The FIRST matching agent marker (exact table then the `CODEX_*` prefix), or `null`. */ export function detectAgentEnv(env: Env): string | null { for (const key of AGENT_ENV_MARKERS) { if (isSet(env[key])) return key; } for (const key of Object.keys(env)) { if (key.startsWith(AGENT_ENV_PREFIX) && isSet(env[key])) return key; } return null; } /** The FIRST matching CI marker, or `null`. */ export function detectCI(env: Env): string | null { for (const key of CI_MARKERS) { if (isSet(env[key])) return key; } return null; } /** True iff a render flag was given explicitly — `--json`, `--format <m>`, or `--agent`. */ function hasRenderFlag(flags: GlobalFlags): boolean { return flags.json === true || flags.format !== undefined || flags.agent === true; } /** * Resolve the Caller from parsed flags, the environment, and the two stream TTY bits. Pure and total. * See the module header for the ladder; the asymmetry (fail-closed to non-interactive) is enforced by * the single `interactive` conjunction at the end — it can only be `true` for a confident human. */ export function resolveCaller(flags: GlobalFlags, env: Env, s: Streams): Caller { const agentEnv = detectAgentEnv(env); const ci = detectCI(env); const renderFlag = hasRenderFlag(flags); // --- kind + detectedBy (env-first, TTY-second) --- let kind: CallerKind; let harness: string | null; let detectedBy: CallerDetection; if (flags.agent === true) { // An explicit `--agent` is an unambiguous machine request. kind = "agent"; harness = null; detectedBy = "flag"; } else if (agentEnv !== null) { // ENV BEATS TTY: an agent holding a PTY is still an agent. kind = "agent"; harness = agentEnv; detectedBy = "env"; } else if (ci !== null) { kind = "agent"; harness = ci; detectedBy = "ci"; } else if (renderFlag) { // A human (or agent) forcing `--json`/`--format`: machine output, but no agent env to attribute. kind = "human"; harness = null; detectedBy = "flag"; } else { // Fell through to the terminal probe. kind = "human"; harness = null; detectedBy = "tty"; } // --- interactive: a CONFIDENT human ONLY (fail-closed) --- // Every clause must hold: no forcing flag, no agent env, no CI, and BOTH streams real TTYs. Any // ambiguity leaves at least one clause false ⇒ non-interactive. const interactive = kind === "human" && !renderFlag && agentEnv === null && ci === null && s.stdinTTY && s.stdoutTTY; return Object.freeze({ kind, harness, detectedBy, interactive }); } /** Resolve the live Caller from `process` — the one place that touches the real terminal + env. */ export function resolveCallerFromProcess(flags: GlobalFlags): Caller { return resolveCaller(flags, process.env as Env, { stdinTTY: Boolean(process.stdin.isTTY), stdoutTTY: Boolean(process.stdout.isTTY), }); }