/** * Trace context: which trace each agent session's turn is working on. * * Every message written into an agent turn passes through * {@link TraceContext.noteTurnDelivery}. The trace of the text (a * `[TRACE:…]` marker, a ticket marker, or a work item / decision / ticket id * the index knows) becomes the session's current trace, and later events of * that session (skill calls, replies, usage) are attributed to it. * * An owner message that names no trace is kept as a *pending root*: the trace * is only created when the turn starts work ({@link TraceContext.ensureTraceForSession}), * so chit-chat makes no trace. * * The current trace ends (so nothing later is billed to a finished run) when: * - a delivery names no trace (cron / scheduled prompts, system notices, * digests, an untraced owner message); * - the session's work item in that trace reaches a terminal status * ({@link TraceContext.clearIfCurrent}); * - the session shows no activity (deliveries, API calls) for the idle gap * ({@link defaultIdleClearMs}). * * All state is in memory; a restart starts clean (new deliveries set it again). * * specs/2026-10-03-run-traces.md * * @module services/trace/trace-context.service */ import { type TraceStore } from './trace-store.js'; import { type TraceActor, type TraceEvent, type TraceEventType, type TraceOutcome, type TraceRefs, type TraceRootKind } from './trace.types.js'; /** How a delivered message reached the turn. */ export type DeliveryKind = 'owner_message' | 'agent_message' | 'dispatch' | 'redelivery' | 'status' | 'decision' | 'follow_up' | 'system'; /** Input of {@link TraceContext.startTrace}. */ export interface StartTraceInput { kind: TraceRootKind; summary: string; actor: TraceActor; refs?: TraceRefs; /** Make it the current trace of this session */ session?: string; now?: Date; } /** Input of {@link TraceContext.record}. */ export interface RecordInput { traceId: string; type: TraceEventType; actor: TraceActor; summary: string; outcome?: TraceOutcome; refs?: TraceRefs; data?: Record; at?: Date; /** * Fraction (0..1] of the underlying fact this trace carries when the same * fact is recorded in several traces (a busy period shared by two tickets). * Not written to the trace; only the listeners see it. Default 1. */ share?: number; } /** What a {@link TraceListener} is told about a recorded event. */ export interface TraceListenerMeta { /** See {@link RecordInput.share} */ share: number; } /** Called for every event recorded through {@link TraceContext.record}. Must not throw. */ export type TraceListener = (event: TraceEvent, meta: TraceListenerMeta) => void; /** * Idle gap after which a session's current trace is dropped: the * `CREWLY_TRACE_IDLE_CLEAR_MINUTES` env var, else * {@link TRACE_CONSTANTS.IDLE_CLEAR_MS}. * * @returns Milliseconds */ export declare function defaultIdleClearMs(): number; /** * Classify a delivered text. * * @param text - Exactly what was written into the turn * @returns The delivery kind */ export declare function classifyDelivery(text: string): DeliveryKind; /** * Per-session trace context. Singleton (see {@link getTraceContext}). */ export declare class TraceContext { private readonly storeOf; private readonly logger; private readonly spans; private readonly pending; /** Sessions whose turn is busy: since when, and the trace it started on */ private readonly busy; private readonly now; private readonly idleClearMs; /** Projects (ids) the teams of a session work on; null when unknown. Set by the wiring. */ private sessionProjects; private readonly listeners; /** * @param storeOf - Store accessor (default: the process-wide store) * @param now - Clock (ms) * @param idleClearMs - Idle gap that ends a session's current trace (default {@link defaultIdleClearMs}) */ constructor(storeOf?: () => TraceStore, now?: () => number, idleClearMs?: number); /** The store. */ get store(): TraceStore; /** * Start a trace and write its root event. * * @param input - Root kind, summary, actor, refs, optional session to bind * @returns The new trace id, or null when the store refused it */ startTrace(input: StartTraceInput): string | null; /** * Be told about every recorded event (the usage ledger folds them into its * facts). Listeners run even when the trace file refused the event (size * cap), and a throwing listener is ignored. * * @param listener - Callback * @returns Function that removes the listener */ addListener(listener: TraceListener): () => void; /** * Append an event (summary cleaned, data trimmed). Never throws. * * @param input - The event * @returns True when queued */ record(input: RecordInput): boolean; /** * Tell the listeners about an event. * * @param event - The recorded event * @param share - See {@link RecordInput.share} */ private notify; /** * The trace the session's turn is working on now. * * @param session - Agent session * @returns Trace id, or null */ currentTrace(session: string | null | undefined): string | null; /** * The session did something (an API call, a delivery): keep its current * trace alive. A trace already past the idle gap is ended first, so a * session coming back after a long pause does not revive it. * * @param session - Agent session * @param at - When (ms) */ touch(session: string | null | undefined, at?: number): void; /** * End the session's current trace if it is this one (its work item * reached a terminal status). * * @param session - Agent session * @param traceId - The trace that finished for it * @returns True when it was cleared */ clearIfCurrent(session: string | null | undefined, traceId: string | null | undefined): boolean; /** * The session's last span, after ending it when the session has been idle * longer than the idle gap (a null span then starts where the gap ended). * * @param session - Agent session * @param at - Now (ms) * @returns The last span after the check, or undefined */ private expireIfIdle; /** * The trace the session was working on at a given time (usage entries are * often recorded after the turn, e.g. by the Claude transcript sync). * * @param session - Agent session * @param at - Time of the entry (ms); default now * @returns Trace id, or null */ traceAt(session: string | null | undefined, at?: number): string | null; /** * Every trace the session was working on at a given time: the one * {@link traceAt} returns first, then the others its delivery named. * * @param session - Agent session * @param at - Time (ms); default now * @returns Trace ids (empty when none) */ tracesAt(session: string | null | undefined, at?: number): string[]; /** * Set (or clear) the session's current trace. * * @param session - Agent session * @param traceId - Trace, or null for none * @param at - Since when (ms) * @param co - Other traces named by the same delivery (shared span) */ setCurrent(session: string, traceId: string | null, at?: number, co?: string[]): void; /** * The session's current trace; when it has none but an owner message is * waiting, that message becomes the root of a new trace now (the turn is * starting work). * * @param session - Agent session * @returns Trace id, or null */ ensureTraceForSession(session: string | null | undefined): string | null; /** * Tell the context which projects a session's teams work on, so a prose * ticket id in a message to an agent of another project does not pull the * agent into that ticket's trace. * * @param resolver - Session → project ids (null / empty = unknown, link kept) */ setSessionProjectResolver(resolver: (session: string) => string[] | null): void; /** * Whether a trace is tagged with an autopilot project that none of the * recipient's teams works on. Unknown (no tag, no recipient, recipient in * no team or unreadable) is never foreign. * * @param traceId - Trace to check * @param recipient - Session the text is going to, when known * @returns True only when the trace's project is known and not the recipient's */ isForeignTo(traceId: string, recipient?: string): boolean; /** * Traces a text refers to: `[TRACE:…]` markers win; otherwise ticket * markers, then work item / decision / ticket ids the index knows. * * A ticket id that appears only in prose (no `[TICKET:]` marker) binds * only when the recipient is not known to be on teams of another project: * a trace tagged with a project that none of `recipient`'s teams works on * is skipped (cross-project mention, CREW-396). * * @param text - Any text * @param recipient - Session the text is going to, when known * @returns Trace ids, in order, de-duplicated */ resolveTracesFromText(text: string, recipient?: string): string[]; /** * A message was written into an agent's turn. Sets the session's current * trace and records `turn.delivered` (see the module doc). Never throws. * * @param session - Agent session written to * @param text - Exactly what was delivered * @param runtime - `pty` or `in-process` * @returns The trace the turn is now on, or null */ noteTurnDelivery(session: string, text: string, runtime?: string): string | null; /** * An agent's turn became busy or idle (PTY activity monitor, in-process * turn start / end). When a busy period ends, one `turn.ended` event with * `data.busyMs` is recorded in the trace the turn started on, and in the * session's current trace when a delivery moved it to another one meanwhile. * Periods shorter than {@link PTY_CONSTANTS.MIN_BUSY_DURATION_MS} are not * recorded (activity-monitor flapping). Repeated busy / idle calls are * ignored, so two sources can report the same turn. Never throws. * * @param session - Agent session * @param busy - True when the turn started, false when it ended * @param runtime - `pty` or `in-process` * @returns True when a `turn.ended` event was recorded */ noteTurnActivity(session: string, busy: boolean, runtime?: string): boolean; /** * Forget everything (tests). */ reset(): void; } /** * The process-wide trace context. * * @returns The context (created on first use) */ export declare function getTraceContext(): TraceContext; /** * Replace the process-wide context (tests). * * @param context - Context, or null to rebuild the default lazily */ export declare function setTraceContextForTesting(context: TraceContext | null): void; //# sourceMappingURL=trace-context.service.d.ts.map