/** * Terminal / harness identity keys for session binding (T12499 · Epic T12497). * * A CLEO session id names a row; it says nothing about WHICH terminal or agent * harness is calling. Before T12499 an unbound terminal fell through to the * newest active session row, so one agent's `cleo` call resolved another * agent's session. This module answers the missing question — "which terminal * am I?" — as an ordered list of opaque binding keys that `session start` * persists (terminal key → session id) and that resolution reads back before * any newest-active fallback. * * ## The key map is DATA * * {@link TERMINAL_KEY_SOURCES} is the single table of environment variables a * provider harness or terminal emulator exports with a per-session / per-pane * value. Adding a harness is one row, never a new `process.env[...]` check at a * call site. Order is precedence: the most specific identity first — an agent * harness session beats the multiplexer pane it runs in, which beats the * terminal tab that hosts the multiplexer. * * These values are NOT CLEO session ids (contrast `SESSION_ENV_KEY_PRECEDENCE` * in `session-id.ts`, whose values ARE CLEO ids). They are opaque identities * that only mean something through a persisted binding. * * ## Harness-ancestor fallback (T12864) * * When no environment key is present, the identity falls back to the nearest * ancestor that identifies ONE caller ({@link resolveAncestor}): a long-lived * interactive or script shell (the terminal tab's shell, a CI step or cron * script), or — past throwaway `sh -c` / `bash -c` layers — a KNOWN agent * harness process ({@link KNOWN_HARNESSES}: Kimi, aider, …). Such harnesses run every tool call in a fresh `bash -c`, so the * immediate parent changes on each call; the harness process does not. Any * other process (a node / python host that may run several agents, a daemon * spawning `cleo` directly) identifies nobody unless `CLEO_AGENT_ID` names the * agent. Pid 1 / launchd / systemd / init, an unreadable shell command line or * depth exhaustion also yield no key: the caller keeps the single-session guard. * * Env tab keys are inherited by every descendant, so when a harness (or a * `CLEO_AGENT_ID` agent) runs below one, its anchor is appended as a more * specific key — two harnesses in one ssh login or CI step stay apart. * * Before T12864 the fallback stopped at the first non-launcher ancestor, which * for a harness is the per-call `bash -c` — a new key on every call, so a * session started in one call was invisible to the next (T12530 therefore * stopped treating it as identity). * * @module * @task T12499 * @epic T12497 */ import { PS_STABLE_ENV } from '@cleocode/contracts/process-probe.js'; /** * Where a terminal key comes from. `process` is a long-lived ancestor shell * (tab tier) and `harness` one agent below it or anywhere (pane tier) — both * T12864; `ppid` is the pre-T12864 per-call fallback, never produced any more * and kept only so rows written before it still type-check. */ export type TerminalKeyKind = 'provider' | 'multiplexer' | 'terminal' | 'process' | 'harness' | 'ppid'; /** * One row of the provider/terminal key map: an environment variable whose value * identifies a single harness session, multiplexer pane or terminal tab. */ export interface TerminalKeySource { /** Environment variable carrying the identity value. */ readonly envVar: string; /** Category of identity (drives precedence documentation only). */ readonly kind: Exclude; /** Harness / terminal that exports the variable. */ readonly origin: string; /** * Optional companion variable that disambiguates the value. `TMUX_PANE` is * `%0` on every tmux server, so it is qualified by `TMUX` (socket path, server * pid, session index) to stay unique across servers. */ readonly qualifierEnvVar?: string; /** * Further qualifiers, applied after {@link qualifierEnvVar} (T12864). A * GitHub Actions run id alone is shared by every job of the run, so it is * qualified by the attempt and the job id. */ readonly extraQualifierEnvVars?: readonly string[]; } /** * The provider/terminal key map, in precedence order (T12499). * * Provider rows cover agent harnesses that export a per-session id to the shell * commands they run. Only variables whose export is documented upstream are * listed: `CLAUDE_CODE_SESSION_ID` (Claude Code), `CODEX_THREAD_ID` (Codex CLI, * `codex-rs/core/src/exec_env.rs`) and `GEMINI_SESSION_ID` (Gemini CLI; its docs * name it for the hook environment). The CAAMP provider definitions carry no * session-id variable for any provider, and none is known for Kimi, so none is * invented here; add a row once one is confirmed. */ export declare const TERMINAL_KEY_SOURCES: readonly TerminalKeySource[]; /** * Launcher processes skipped by the ppid-chain walk: they sit between the shell * and the `cleo` process (`pnpm exec`, a mise shim, `npx`) and carry no terminal * identity of their own. */ export declare const PPID_CHAIN_LAUNCHERS: ReadonlySet; /** * Environment overrides for the `ps` probe (T12500): UTC and the C locale make * the `lstart` start time — part of every ppid-chain key — independent of the * caller's `TZ` / `LANG`. Defined in `@cleocode/contracts` so the worktree * lock (T12506) renders start times identically. */ export { PS_STABLE_ENV }; /** Maximum number of ancestors the harness-ancestor walk inspects (T12864). */ export declare const PPID_CHAIN_MAX_DEPTH: 12; /** * Agent harnesses recognised by the ancestor walk (T12864), by PROGRAM name: * the executable, or the script / `-m` module a `node` / `python` runtime runs * (see {@link programName}). A listed process becomes a caller identity, even * though it runs every tool call in a fresh `bash -c`. * * List ONLY processes that host exactly ONE agent session for their whole * life. A process that hosts several (an IDE extension host, CrewAI, * LangGraph, OpenHands, a server that clients attach to) must NOT be listed: * its agents would share one session. Such hosts set `CLEO_AGENT_ID` per agent. * Avoid names that other common tools also use. * * To add a harness: append the program name its process shows (check with * `ps -o args= -p ` while it runs a tool call), after confirming it is * single-agent. * * Deliberately NOT listed: * - `opencode`: `opencode serve` / `opencode web` run one server process that * hosts many sessions and clients (`opencode attach`, `opencode run * --attach`, `GET /session/status` for all sessions; opencode.ai/docs/server), * and tools run in that server process. * - `goose`: collides with pressly/goose, the database migration tool. */ export declare const KNOWN_HARNESSES: ReadonlySet; /** * Thin wrappers between a caller and `cleo` (T12864). They live exactly as long * as the command they wrap, so the walk skips them. Matched on program name, so * `npm exec …` (npx's process title) and `node …/npm-cli.js` both match. */ export declare const ANCESTOR_WRAPPERS: ReadonlySet; /** * The system's init / service manager (T12864). An ancestor walk that reaches * one of these (or pid 1) found no caller-owned process. */ export declare const INIT_PROCESSES: ReadonlySet; /** One resolved identity key for the calling terminal. */ export interface TerminalKey { /** Opaque, stable binding key (`env:=`, `proc:@` or `harness:…`). */ readonly key: string; /** Environment variable name, or `'process'` for the harness-ancestor fallback. */ readonly source: string; /** Identity category. */ readonly kind: TerminalKeyKind; } /** One ancestor process as read from the OS process table. */ export interface ProcessAncestor { /** Process id. */ readonly pid: number; /** Parent process id. */ readonly ppid: number; /** Process start time as reported by the OS (disambiguates recycled pids). */ readonly startedAt: string; /** Executable base name (e.g. `zsh`, `node`). */ readonly command: string; } /** Reads one process-table entry; returns `null` when the pid is unknown. */ export type ProcessLookup = (pid: number) => ProcessAncestor | null; /** Reads a process's full command line; returns `null` when it cannot be read (T12864). */ export type ProcessArgsLookup = (pid: number) => string | null; /** Inputs for {@link resolveTerminalKeys}; every field defaults to the live process. */ export interface ResolveTerminalKeysOptions { /** Environment to read (defaults to `process.env`). */ readonly env?: NodeJS.ProcessEnv; /** Parent pid to start the ppid-chain walk from (defaults to `process.ppid`). */ readonly ppid?: number; /** Process-table reader (defaults to {@link readProcessEntry}). */ readonly lookupProcess?: ProcessLookup; /** Command-line reader for shells (defaults to {@link readProcessArgs}). */ readonly lookupArgs?: ProcessArgsLookup; } /** * Read one entry from the OS process table via `ps` (POSIX only). * * Returns `null` on Windows, for an unknown pid, or when `ps` fails — the * ppid-chain fallback is best-effort and must never fail a command. * * @param pid - Process id to inspect. * @returns The process entry, or `null`. */ export declare function readProcessEntry(pid: number): ProcessAncestor | null; /** * Read a process's command line via `ps -o args=` (POSIX only, T12864). * * Tells a throwaway `sh -c '…'` from a long-lived shell, and names the program * a runtime runs. Returns `null` on Windows, for an unknown pid, or when `ps` * fails. * * @param pid - Process id to inspect. * @returns The command line, or `null`. */ export declare function readProcessArgs(pid: number): string | null; /** * The program a process runs, from its command line (T12864): the executable's * base name, or — for a language runtime ({@link RUNTIME_PATTERN}) — the * `-m` module or script it runs (extension stripped). Matches on the FIRST * word, so a process title such as `npm exec cleo` is `npm`. * * @param commandLine - `ps -o args=` output (or the bare command name). * @returns The program name. * @example * ```ts * programName('/usr/bin/python3.12 -m aider --yes'); // 'aider' * programName('npm exec cleo start T1'); // 'npm' * ``` */ export declare function programName(commandLine: string): string; /** * Whether a shell's command line runs a single command string (`sh -c '…'`, * `bash -lc '…'`, `pwsh -Command …`) — a throwaway shell that exits with the * command — rather than an interactive or script shell that outlives it (T12864). * * POSIX-style shells: options are read up to the first operand. `-c`, * `--command` / `--commands` (fish, nu), or a short-option cluster containing * `c` means command mode; the arguments of `-o` / `+o` / `-O` / `+O` / * `--rcfile` / `--init-file` are skipped rather than read as the operand. * PowerShell: any `-Command` / `-c` / `-EncodedCommand` / `-CommandWithArgs`. * * @param args - The shell's full command line. * @returns `true` for a command-string shell. */ export declare function isCommandStringShell(args: string): boolean; /** What the ancestor walk anchored on (T12864). */ export interface AncestorAnchor { /** The anchoring process. */ readonly entry: ProcessAncestor; /** Its program name ({@link programName}). */ readonly program: string; /** * `shell` — a long-lived interactive / login / script shell (the terminal's * own identity); `harness` — a {@link KNOWN_HARNESSES} process; `host` — any * other process, anchored ONLY because `CLEO_AGENT_ID` names the agent. Set * `CLEO_AGENT_ID` only in a LONG-LIVED host: with it, the first non-harness * ancestor anchors, even a short-lived `make`, and the key then changes on * every call. */ readonly type: 'shell' | 'harness' | 'host'; } /** * Walk to the nearest ancestor that identifies ONE caller across its `cleo` * calls (T12864). * * From `cleo`'s parent upwards, by program name ({@link programName}): * * - wrappers ({@link ANCESTOR_WRAPPERS}) are skipped; * - a shell that is not a command-string shell ({@link isCommandStringShell}) * is long-lived — one per terminal tab, CI step or script: `shell`; * - a command-string shell (`bash -c`) is skipped; * - a {@link KNOWN_HARNESSES} process is `harness`; * - any other process ends the walk: it may host SEVERAL agents (a node or * python orchestrator, an IDE extension host, a daemon spawning `cleo` * directly), so it identifies nobody — unless `agentId` (`CLEO_AGENT_ID`) * names the agent, which makes it a `host` anchor. * * Returns `null` — no identity, the single-session guard applies — at pid 1 or * an init process, when a shell's command line cannot be read, when the * process table cannot be read, or past {@link PPID_CHAIN_MAX_DEPTH}. * * @param ppid - Parent pid to start from. * @param lookupProcess - Process-table reader. * @param lookupArgs - Command-line reader. * @param agentId - `CLEO_AGENT_ID`, when set. * @returns The anchor, or `null`. * @task T12864 */ export declare function resolveAncestor(ppid: number, lookupProcess: ProcessLookup, lookupArgs: ProcessArgsLookup, agentId?: string | null): AncestorAnchor | null; /** Interactive / script shells skipped by {@link resolveOwnerProcess}, beyond the launchers. */ export declare const OWNER_PROCESS_SHELLS: ReadonlySet; /** Maximum number of ancestors {@link resolveOwnerProcess} inspects. */ export declare const OWNER_PROCESS_MAX_DEPTH: 8; /** * Resolve the long-lived process that OWNS the calling `cleo` invocation * (T12506): the nearest ancestor that is neither a launcher * ({@link PPID_CHAIN_LAUNCHERS}) nor a shell ({@link OWNER_PROCESS_SHELLS}). * * Agent harnesses run each tool call in a fresh `sh -c`, so the parent shell * dies the moment `cleo orchestrate spawn` returns; the harness above it * (e.g. `claude`, `codex`) lives as long as the agents it spawned. The worktree * lock records this process so its liveness keeps the lock held. When every * inspected ancestor is skippable, the last one read is returned. * * @param ppid - Parent pid to start from (defaults to `process.ppid`). * @param lookupProcess - Process-table reader (defaults to {@link readProcessEntry}). * @returns The owner process, or `null` when the chain cannot be read. */ export declare function resolveOwnerProcess(ppid?: number, lookupProcess?: ProcessLookup): ProcessAncestor | null; /** * Resolve the calling terminal's identity keys, most specific first (T12499). * * Every {@link TERMINAL_KEY_SOURCES} row whose variable is set contributes one * key. Unless a provider key is present, the ancestor walk * ({@link resolveAncestor}) then runs: with no env key its anchor is the only * key; under env keys an agent-level anchor (a harness, or `CLEO_AGENT_ID`) is * appended as the most specific key. The walk is memoised per process. * * Synchronous and database-free; binding persistence and lookup live in * `store/session-binding-store.ts`. * * @param options - Environment / process-table overrides (tests inject these). * @returns Ordered identity keys; empty when nothing identifies the terminal. * * @example * ```ts * resolveTerminalKeys({ env: { TMUX: '/tmp/tmux-501/default,123,0', TMUX_PANE: '%3' } }); * // → [{ key: 'env:TMUX_PANE=/tmp/tmux-501/default,123,0|%3', source: 'TMUX_PANE', kind: 'multiplexer' }] * ``` */ export declare function resolveTerminalKeys(options?: ResolveTerminalKeysOptions): TerminalKey[]; //# sourceMappingURL=terminal-identity.d.ts.map