/** * Outpost on-box session heartbeat emitter — mission-control US-009. * * Runs ON the Outpost VM (not in a Lambda). On a fixed cadence it: * 1. enumerates the box's local Claude Code (`~/.claude/projects/**\/.jsonl`) * and Codex (`~/.codex/session_index.jsonl` + `sessions/YYYY/MM/DD/rollout-*.jsonl`) * sessions using cheap scandir + stat + BOUNDED tail/head reads only — * it NEVER full-parses a multi-MB transcript; * 2. summarizes them into a compact `AgentSession[]` payload with * `origin="outpost"`, mirroring the local reader logic from US-002/US-003; * 3. publishes that payload to the realtime fabric topic `hq/{personUid}/sessions` * using the same on-box credential pattern the rest of the box uses * (a server-minted, per-identity-scoped STS session vended by * `POST /v1/realtime/credentials`, then an MQTT-over-WSS publish). * * Security (US-009 acceptance): the payload carries ONLY the AgentSession * fields below — never a transcript body, prompt, token, API key, env var, or * credential. `assertNoSecretsInPayload` is the runtime guard, and the unit * tests assert the no-secrets-in-payload guarantee against adversarial * fixtures. * * This module is intentionally dependency-light and pure-logic where it can be: * the filesystem, clock, and publish transport are all injected so the * enumeration → payload mapping and the no-secrets guarantee are unit-testable * without a real VM, real MQTT, or real STS. */ /** Which agent tool produced the session. */ export type AgentTool = "claude" | "codex"; /** Where the session physically lives. The outpost emitter always emits `outpost`. */ export type AgentOrigin = "local" | "outpost"; /** * Session liveness taxonomy (US-001). Derived best-effort from a last-activity * mtime window. `awaiting_input` is not inferable from on-disk artifacts alone * on the box, so the emitter only ever produces `running | idle | ended`; the * desktop merges/cross-checks and may surface `awaiting_input` for local PIDs. */ export type AgentStatus = "running" | "awaiting_input" | "idle" | "ended"; /** * Unified, compact agent session summary. This is the ONLY shape that crosses * the wire — no transcript bodies, no secrets. Matches the Rust struct + * TS type defined in the hq-sync repo (US-001). */ export interface AgentSession { /** Stable session id (the `` for Claude, the rollout/index id for Codex). */ id: string; tool: AgentTool; origin: AgentOrigin; /** Working directory the session is running in, if known. */ cwd: string | null; /** Project slug/name (last path segment of cwd, or decoded Claude project dir). */ project: string | null; /** Owning company slug, if resolvable from HQ workspace metadata. */ company: string | null; /** Model id last seen for the session, if observed in a bounded read. */ model: string | null; status: AgentStatus; /** ISO-8601 first-seen / creation time, if known. */ startedAt: string | null; /** ISO-8601 last-activity time (file mtime is the liveness signal). */ lastActivityAt: string | null; /** Provenance of this record — the on-box file we summarized. Path only, never content. */ source: string; } /** Liveness thresholds (seconds). Mirrors the desktop liveness engine (US-004). */ export interface LivenessThresholds { /** ≤ this since last activity ⇒ `running`. */ runningWithinSeconds: number; /** ≤ this (and > running) ⇒ `idle`; beyond ⇒ `ended`. */ idleWithinSeconds: number; } /** * Default cadence matches the desktop polling interval (~5s). Configurable via * the `OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS` env var so dev/staging can * dial it without a rebuild — read by `resolveCadenceSeconds`. */ export declare const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 5; /** * Default liveness windows. `running` ⇐ activity within the last 2 cadence * ticks (10s); `idle` out to 15m; older ⇒ `ended`. Kept generous so a session * mid-think between writes isn't flapped to `ended`. */ export declare const DEFAULT_LIVENESS_THRESHOLDS: LivenessThresholds; /** A directory entry as returned by the filesystem port. */ export interface DirEntry { name: string; isDirectory: boolean; isFile: boolean; } /** Minimal stat surface used by the enumerator. */ export interface FileStat { /** Last-modification time, ms since epoch. */ mtimeMs: number; /** Birth/creation time, ms since epoch (may equal mtime on filesystems w/o btime). */ birthtimeMs: number; size: number; } /** * Filesystem port — abstracts node:fs so tests drive an in-memory tree and the * real emitter uses `nodeFileSystem`. Every read here is bounded. */ export interface FileSystemPort { /** Returns [] when the dir is missing — enumeration must not throw on absence. */ readDir(path: string): Promise; stat(path: string): Promise; /** Whole-file read — used ONLY for the tiny Codex index, never for transcripts. */ readTextFile(path: string): Promise; /** * Bounded read: at most `maxBytes` from the END of the file (tail) or the * START (head). Implementations MUST NOT load the whole file. Returns "" on * any error (missing/locked) — enumeration is best-effort. */ readBounded(path: string, maxBytes: number, from: "head" | "tail"): Promise; } /** Publishes the compact payload to the realtime topic. Injected for tests. */ export type PublishPort = (topic: string, payload: SessionsHeartbeatPayload) => Promise; /** The full envelope published to `hq/{personUid}/sessions`. */ export interface SessionsHeartbeatPayload { /** Schema discriminator for the desktop subscriber. */ type: "sessions"; /** Always `outpost` from this emitter. */ origin: "outpost"; /** ISO-8601 emit time. */ emittedAt: string; /** The compact session summaries — live only, newest first, size-bounded. */ sessions: AgentSession[]; /** * How many sessions the box actually has on disk, including the `ended` * archive that is deliberately not published. Present so a consumer can tell * "this box has 15 sessions" from "this box has 9,340 and we sent the live * 15" — a filtered list that looks complete is worse than no list. */ totalSessions?: number; /** True when the byte budget forced sessions to be dropped. */ truncated?: boolean; } /** * Serialized-payload ceiling, in bytes. * * AWS IoT Core hard-rejects publishes over 128 KiB (131,072) — the box's first * real heartbeat died on exactly that. This budget sits under it with headroom * for the envelope and for any field a future schema adds. */ export declare const IOT_PAYLOAD_BUDGET_BYTES: number; export interface HeartbeatConfig { /** Caller's canonical HQ person id (`prs_*`). Topic = `hq/{personUid}/sessions`. */ personUid: string; /** Home directory to scan (defaults to the process HOME). */ home?: string; /** Liveness thresholds (defaults to {@link DEFAULT_LIVENESS_THRESHOLDS}). */ thresholds?: LivenessThresholds; /** Clock injection for deterministic tests. */ now?: () => Date; } export interface HeartbeatDeps { fs: FileSystemPort; publish: PublishPort; } /** The sessions topic for a person. `hq/{personUid}/sessions`. */ export declare function sessionsTopicForPerson(personUid: string): string; /** Resolve the heartbeat cadence (seconds) from env, clamped to a sane floor. */ export declare function resolveCadenceSeconds(env?: NodeJS.ProcessEnv): number; /** * Map an mtime to a status given the thresholds and `now`. On the box we have * no per-session PID cross-check (that's the desktop's job), so we only emit * `running | idle | ended` — the desktop refines from there. */ export declare function deriveStatus(lastActivityMs: number, nowMs: number, thresholds?: LivenessThresholds): AgentStatus; /** Decode Claude's `-`-joined project dir back to a best-effort cwd. */ export declare function decodeClaudeProjectDir(dirName: string): string; interface CodexIndexRecord { id: string; cwd: string | null; model: string | null; timestamp: string | null; /** Relative path under ~/.codex, when the index records it. */ path: string | null; } /** Parse the small newline-delimited Codex index into records. */ export declare function parseCodexIndex(text: string): CodexIndexRecord[]; /** * Project an arbitrary session-like object down to EXACTLY the whitelisted * AgentSession fields. Any extra key (e.g. a transcript snippet, token, env * var) is dropped here — this is the structural half of the no-secrets * guarantee. */ export declare function toCompactSession(s: AgentSession): AgentSession; /** * Runtime guard: throw if the payload carries any non-whitelisted key OR any * value that looks like a secret. The behavioral half of the no-secrets * guarantee — defense in depth on top of `toCompactSession`. Called before * every publish. */ export declare function assertNoSecretsInPayload(payload: SessionsHeartbeatPayload): void; /** * Enumerate the box's Claude + Codex sessions and build the compact, * secret-free payload. Pure w.r.t. the injected fs/clock — does NOT publish. */ export declare function collectSessions(config: HeartbeatConfig, deps: Pick): Promise; /** * One heartbeat tick: collect → guard → publish to `hq/{personUid}/sessions`. * Best-effort and non-fatal by contract — a publish failure must not crash the * box's heartbeat loop (the desktop falls back to the S3-vault heartbeat / * stale-timeout, US-011). Returns the payload that was published (or attempted) * so callers/tests can assert on it; re-throws nothing. */ export declare function emitHeartbeatOnce(config: HeartbeatConfig, deps: HeartbeatDeps): Promise; /** Production FileSystemPort backed by node:fs with bounded positioned reads. */ export declare const nodeFileSystem: FileSystemPort; export {}; //# sourceMappingURL=session-heartbeat.d.ts.map