import { type MeteringDay } from './session-metering.js'; import { type DayRange } from './activity-range.js'; import type { CensusSession, PtyCensusSnapshot } from './pty-census.js'; import type { FsWatcher } from './fs-watcher.js'; import type { Logger } from './pty-census.js'; /** The watcher-derived half of a row. Narrowed to the three fields the join * reads so the pure builder needs no SessionRow fixture. */ export interface ActivityInputRow { /** The resolved account, cwd first and sidecar second (resolveRowAccountId). * Null only when NEITHER source names a registered account dir — rendered * `untagged`, and never folded into the house account. */ accountId: string | null; status: string; /** Sidecar `startedAt`, ISO. The session's real start, and the only honest * age source: the JSONL mtime is the LAST write, so using it would make * every row's age equal its idle. Null when no sidecar exists. */ startedAt: string | null; } /** The JSONL-enumerator half of a row. */ export interface ActivityEnriched { title: string | null; model: string | null; lastMessageAt: string | null; /** Task 1801 — the transcript's FIRST turn, and the Age source. */ firstMessageAt: string | null; turnCount: number; /** True when the tail parse was clipped, making turnCount a lower bound. */ capped: boolean; } /** The metering half of a row. * * Carries the DAY ROWS, not a pre-summed cost. The range is applied per * request while the parse is memoised per transcript, so changing the range is * a filter over data already in hand rather than a re-parse of every live * transcript on the box (Task 1827). Each day still prices its own tokens under * BOTH assumptions, because that is what MeteringSummary produces; the join * picks the arm. */ export interface ActivityMetering { days: MeteringDay[] | null; error: string | null; } /** Why a row has no cost. Three distinct facts that would otherwise render as * the same dash: the model is not in the metering's price table at all * (Haiku and Fable are configured models and neither is priced), the metering * parse failed, or the session has no transcript yet. A null with no reason is * the unexplained absence this view exists to eliminate. */ export type CostUnavailable = 'model-not-priced' | 'metering-failed' | 'no-transcript' | null; export interface ActivityRow { sessionId: string; pid: number; accountId: string | null; title: string | null; model: string | null; status: string | null; /** ms since the conversation's FIRST turn. Null when the session has taken no * turn yet — now the only way Age can be blank. * * Sourced from the transcript rather than the sidecar's `startedAt`, which * `pty-spawner` re-stamps to the current PTY on resume and which would * therefore report process age under a column that means conversation age * (Task 1801). */ ageMs: number | null; /** ISO timestamp of the last turn, rendered as a clock time. Null when the * session has taken no turn yet. * * An elapsed duration answered "how stale"; the operator's question is "when * did this last speak", and a duration forces them to subtract against a * clock they cannot see (Task 1801). */ lastMessageAt: string | null; /** True when the watcher resolved no sidecar for this live row. Not rendered — * it feeds the census count that makes a slug-resolution regression visible * without an operator noticing a blank column, because the miss itself emits * no error (Task 1801). */ sidecarMissing: boolean; /** Null when the session has no JSONL yet. A lower bound when capped. */ turnCount: number | null; turnCountCapped: boolean; /** Scope-subtree memory.current in kB. Null when the census did not * attribute this session — unmeasured, not zero-sized. */ rssKb: number | null; /** Null when unmeasured. Never 0 to mean idle. See CensusSession.cpuPct. */ cpuPct: number | null; /** Active time inside the requested range: thinking + messaging + toolUse * summed over the range's MeteringDay entries. * * This is WORKED time. `ageMs` beside it is wall-clock since the first turn * and is dominated by idle human-wait, which is why the page showed `2d 17h` * for a session that had done `1h 35m` of work. * * Null ONLY when metering itself failed. A range containing no days is 0 — * a measured, genuinely quiet period — and the two must never collapse: the * page renders a dash for the first and `0h 0m` for the second (Task 1827). */ activeMsInRange: number | null; /** Tokens billed inside the range, summed over MeteringDay.tokens. * * REPLACES `usedTokens`, which was the live context-window snapshot — a * point-in-time reading that no date range can bound. Same null rule as * activeMsInRange. */ tokensInRange: number | null; /** The costGBP arm matching this row's own model, in GBP. Null when the * model is unknown, maps to neither priced family, or metering failed. */ costGbp: number | null; /** Why costGbp is null, so the rendered dash carries a reason. Null when a * cost is present. */ costUnavailable: CostUnavailable; /** The metering error message for THIS row. Null on success. A failure * costs one row its cost cell and nothing else. */ meteringError: string | null; /** Set when the transcript could not be read for this row (a rename or a * permissions change racing the enumeration). The row still appears with * null metadata, because the session is live regardless. */ enrichError: string | null; } /** Pick the price arm matching the session's own model. * * MeteringSummary prices one token count under TWO assumptions (Opus rates * and Sonnet rates) and does not know which model actually ran. Rendering the * opus arm beside a sonnet session would be a precise-looking falsehood, so a * model in neither family yields null rather than a default. * * Substring rather than exact match because the id namespace carries suffixes * (`claude-sonnet-5[1m]` is the same family and the same price arm). */ export declare function costForModel(model: string | null, cost: { opus: number; sonnet: number; } | null): number | null; /** The account a live session belongs to, resolved the way the rest of this * service resolves it: **cwd first, sidecar second**. * * This ordering is not a preference. `cwd` is the spawn directory read from * claude's own PID file, and every session is spawned inside its account's * directory — so for a live row it is the strongest evidence there is. The * sidecar's `accountId` exists for rows that have no live process (it survives * the process, per Task 1769) and is absent on any session predating Task 983's * stamping or spawned by a path that writes no sidecar. * * Reading the sidecar alone is what rendered a live G.L.Smith & Sons session * as `untagged` while its own cwd named the account. Mirrors `forSessionRow` * in account-title-stores.ts so the activity view and the title store cannot * disagree about whose session this is. * * Returns null only when neither source resolves a REGISTERED account dir — * an id naming a since-deleted account is not an attribution. */ export declare function resolveRowAccountId(row: { cwd: string | null; accountId: string | null; }, accountsRoot: string): string | null; /** The pure join. Every source is a Map keyed by sessionId; the live set is * the spine and every other source is a left join onto it. */ export declare function buildActivityRows(input: { live: Array<{ sessionId: string; pid: number; }>; rows: Map; census: Map; enriched: Map; metering: Map; enrichErrors?: Map; nowMs: number; /** Inclusive UTC day range for the metering figures, or null for every day. */ range: DayRange | null; }): ActivityRow[]; /** Fixed-size worker pool. Bounds the metering fan-out: computeMetering is a * FULL JSONL parse per session, so an unbounded Promise.all over a busy box * would read every transcript at once. * * Rejections propagate. The metering caller catches per item so one bad * transcript costs one row; swallowing here would hide a systematic failure * behind a page of empty cost cells. */ export declare function mapWithConcurrency(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise; export interface CollectActivityDeps { watcher: FsWatcher; claudeConfigDir: string; /** `/data/accounts`. The registry both account sources are checked * against, so an id that names no registered dir is not an attribution. */ accountsRoot: string; jsonlReadMaxBytes: number; liveSessions: Array<{ sessionId: string; pid: number; }>; censusLatest: () => PtyCensusSnapshot | null; logger: Logger; nowMs?: number; /** Bound on concurrent full-JSONL metering parses. */ meteringConcurrency?: number; /** Inclusive UTC day range for the metering figures, or null for all time. */ range?: DayRange | null; /** True when a range WAS requested and could not be honoured. Carried through * so the response and the log can both say the page is showing all-time * figures under a bounded heading. */ fullRangeFallback?: boolean; } export interface ActivityResult { rows: ActivityRow[]; census: { sampleAtMs: number; live: number; cpuNull: number; } | null; /** The range actually applied, echoed so the page can label its own figures * with what it got rather than with what it asked for. */ range: DayRange | null; fullRangeFallback: boolean; } /** Test seam — the memo is module state, so a suite asserting parse counts * needs to clear it between cases. */ export declare function __clearMeteringMemo(): void; /** The impure half — gathers each source, then delegates the arithmetic to * `buildActivityRows`. */ export declare function collectActivity(deps: CollectActivityDeps): Promise; //# sourceMappingURL=activity-rows.d.ts.map