@frontera-sdk/cli 1.53.21 → 1.53.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ import type { AttributeValue } from './events'
2
+
3
+ /**
4
+ * The coding agents' own events, and which of them leave the laptop.
5
+ *
6
+ * Forge points the agents it starts (Claude Code, Codex) at its own loopback
7
+ * relay, never at the collector. The relay keeps what these rules keep and
8
+ * writes it to the outbox, so the sender posts it with Forge's events. The
9
+ * Kata collector applies the same rules again
10
+ * (deployments/kata/telemetry/collector.yaml); a test builds the collector's
11
+ * statements from this file and fails when the two disagree.
12
+ */
13
+
14
+ /** The `service.name` each agent's exporter sends (measured): Claude Code, and the `codex app-server` Forge starts. */
15
+ export const AGENT_SERVICES = ['claude-code', 'codex-app-server'] as const
16
+ export type AgentService = (typeof AGENT_SERVICES)[number]
17
+
18
+ export const AGENT_EVENTS: Readonly<Record<AgentService, RegExp>> = {
19
+ 'claude-code': /^(user_prompt|api_request|api_error|api_refusal|api_retries_exhausted|tool_result|tool_decision|compaction|subagent_completed|internal_error)$/,
20
+ 'codex-app-server': /^codex\.(conversation_starts|user_prompt|api_request|sse_event|tool_decision|tool_result|turn_cost|turn_ttft)$/,
21
+ }
22
+
23
+ /**
24
+ * The record attributes kept from an agent: counts, timings, models and
25
+ * outcomes. Never prompts, responses, tool input or output, error text, or
26
+ * the names of people, accounts, hosts, files or MCP servers.
27
+ */
28
+ export const AGENT_ATTRIBUTES =
29
+ /^(event\.(name|kind|sequence)|frontera\.session\.id|session\.id|conversation\.id|prompt\.id|turn\.(id|interrupted)|prompt_length|model|query_source|speed|effort|reasoning_effort|model_reasoning_effort|cost_usd|usage\.estimated_usd|duration_ms|ttft_ms|attempt|status_code|success|error_type|decision|decision_type|decision_source|source|tool_name|tool_origin|tool_input_size_bytes|tool_result_size_bytes|arguments_length|output_length|input_tokens|output_tokens|cache_read_tokens|cache_creation_tokens|input_token_count|output_token_count|cached_token_count|cache_write_token_count|reasoning_token_count|tool_token_count|approval_policy|sandbox_policy)$/
30
+
31
+ export const MAX_AGENT_ATTRIBUTES = 64
32
+
33
+ /** An MCP tool's name carries its server's, which the person chose: kept only for Frontera's own. */
34
+ export const MCP_TOOL = /^mcp__/
35
+ export const FRONTERA_MCP_TOOL = /^mcp__frontera__/
36
+ /** Codex names an MCP tool by its bare name and its server in `mcp_server` (measured, 0.151): the same rule by the server. */
37
+ export const FRONTERA_MCP_SERVER = 'frontera'
38
+
39
+ const thirdPartyMcp = ({ tool_name: tool, mcp_server: server }: Readonly<Record<string, unknown>>) =>
40
+ typeof tool === 'string' && ((MCP_TOOL.test(tool) && !FRONTERA_MCP_TOOL.test(tool)) || (typeof server === 'string' && server !== '' && server !== FRONTERA_MCP_SERVER))
41
+
42
+ /** Codex's environment for a Forge session (`-c otel.environment="forge:<id>"`), which it sends as the resource attribute `env`. */
43
+ export const FORGE_ENVIRONMENT = /^forge:./
44
+
45
+ export const isAgentService = (service: unknown): service is AgentService => (AGENT_SERVICES as readonly unknown[]).includes(service)
46
+
47
+ /**
48
+ * The Forge session a record belongs to: `frontera.session.id` from
49
+ * `OTEL_RESOURCE_ATTRIBUTES`, or for Codex its `forge:` environment. Null for
50
+ * a record from no Forge session (a copied environment, a program an agent
51
+ * started), which is never kept.
52
+ */
53
+ export function agentSession(resource: Readonly<Record<string, AttributeValue>>): string | null {
54
+ const session = resource['frontera.session.id']
55
+ if (typeof session === 'string' && session) return session
56
+ const env = resource.env
57
+ if (resource['service.name'] === 'codex-app-server' && typeof env === 'string' && FORGE_ENVIRONMENT.test(env)) return env.replace(/^forge:/, '')
58
+ return null
59
+ }
60
+
61
+ /**
62
+ * An agent record's attributes as they may leave, or null when the event is
63
+ * not one that leaves: only the listed keys with plain values, an MCP tool
64
+ * named `mcp` unless it is Frontera's, at most 64 attributes.
65
+ */
66
+ export function agentAttributes(service: AgentService, name: string, attributes: Readonly<Record<string, unknown>>): Record<string, AttributeValue> | null {
67
+ if (!AGENT_EVENTS[service].test(name)) return null
68
+ // Codex sends one sse_event per streamed event; only the last of a response carries the token counts.
69
+ if (name === 'codex.sse_event' && attributes['event.kind'] !== 'response.completed') return null
70
+ const masked = thirdPartyMcp(attributes)
71
+ const kept: Record<string, AttributeValue> = {}
72
+ for (const [key, value] of Object.entries(attributes)) {
73
+ if (Object.keys(kept).length >= MAX_AGENT_ATTRIBUTES) break
74
+ if (!AGENT_ATTRIBUTES.test(key) || !['string', 'number', 'boolean'].includes(typeof value)) continue
75
+ if (typeof value === 'number' && !Number.isFinite(value)) continue
76
+ kept[key] = key === 'tool_name' && masked ? 'mcp' : (value as AttributeValue)
77
+ }
78
+ return kept
79
+ }
@@ -0,0 +1,142 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import { applyFailureReason } from '../local/local-apply-error'
6
+ import type { SyncTarget } from '../local/local-dev-sync'
7
+ import type { LocalProject } from '../local/local-project'
8
+ import { cliTelemetry } from './cli-telemetry'
9
+ import type { ApplyOutcome, ApplyTrigger, Severity } from './events'
10
+ import type { Recorder } from './recorder'
11
+ import { errorCode, errorFields, outputErrorCode } from './redact'
12
+
13
+ /** An item failing since `since` (unix ms), after `failures` failed applies. */
14
+ export interface Streak {
15
+ failures: number
16
+ since: number
17
+ }
18
+
19
+ /**
20
+ * One apply's place in its item's error round trip. A failure starts or
21
+ * lengthens the streak; the success after it ends the streak and says how many
22
+ * failed applies and how long it took to go green. An apply that never reached
23
+ * the item's files (the runtime was down, the watchdog stopped it) leaves the
24
+ * streak as it is.
25
+ */
26
+ export function stepStreak(streak: Streak | undefined, outcome: ApplyOutcome, now: number): { next: Streak | undefined; attempt: number; recovered?: { attempts: number; ms: number } } {
27
+ if (outcome === 'failed') {
28
+ const next = { failures: (streak?.failures ?? 0) + 1, since: streak?.since ?? now }
29
+ return { next, attempt: next.failures }
30
+ }
31
+ const attempt = (streak?.failures ?? 0) + 1
32
+ if (outcome !== 'applied') return { next: streak, attempt }
33
+ return { next: undefined, attempt, ...(streak ? { recovered: { attempts: streak.failures, ms: Math.max(0, now - streak.since) } } : {}) }
34
+ }
35
+
36
+ /** Kept in the project's local state, so `local dev --once` runs, each its own process, add up to one round trip. */
37
+ export const STREAKS_FILE = 'telemetry-streaks.json'
38
+
39
+ /**
40
+ * A streak older than this is forgotten: an item that failed on Friday and
41
+ * was fixed on Monday says nothing about how long a fix takes, so its success
42
+ * carries no round trip, and its next failure starts a new streak.
43
+ */
44
+ export const STREAK_MAX_MS = 8 * 60 * 60_000
45
+
46
+ function readStreaks(path: string, now: number): Record<string, Streak> {
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, Streak>
49
+ if (!parsed || typeof parsed !== 'object') return {}
50
+ return Object.fromEntries(Object.entries(parsed).filter(([, s]) => now - s.since <= STREAK_MAX_MS))
51
+ } catch {
52
+ return {}
53
+ }
54
+ }
55
+
56
+ /** The strings in a target that are names (its slug, its file, its key): replaced in an error template wherever they appear. */
57
+ export function targetNames(target: SyncTarget): string[] {
58
+ const values = Object.entries(target).flatMap(([k, v]) => (k !== 'kind' && typeof v === 'string' ? [v] : []))
59
+ return [...new Set([...values, ...values.map((v) => v.split(/[:/\\]/).pop()!)])].filter((v) => v !== target.kind)
60
+ }
61
+
62
+ const SEVERITY: Record<ApplyOutcome, Severity> = { applied: 'INFO', failed: 'ERROR', runtime_down: 'WARN', stopped: 'WARN' }
63
+
64
+ /**
65
+ * `frontera.cli.apply` and `frontera.cli.apply_round` for one `local dev`.
66
+ * Item names never leave: only the item's kind and its salted hash.
67
+ */
68
+ export function applyEvents(project: LocalProject, opts: { recorder?: Recorder; now?: () => number } = {}) {
69
+ const telemetry = opts.recorder ?? cliTelemetry()
70
+ const now = opts.now ?? Date.now
71
+ const on = telemetry.mode.mode !== 'off'
72
+ const projectHash = on ? telemetry.hash(project.id) : ''
73
+ const streaksPath = join(project.stateDir, STREAKS_FILE)
74
+ // Log mode writes nothing into the project: its streaks live as long as this process.
75
+ const kept = telemetry.mode.mode === 'send'
76
+ let memory: Record<string, Streak> = {}
77
+ const read = () => (kept ? readStreaks(streaksPath, now()) : memory)
78
+ const write = (streaks: Record<string, Streak>) => {
79
+ if (!kept) memory = streaks
80
+ // ponytail: read-modify-write without a lock; a watcher and a `--once` stepping the same file at once can drop one step.
81
+ else writeFileAtomic(streaksPath, `${JSON.stringify(streaks)}\n`)
82
+ }
83
+
84
+ const step = (key: string, outcome: ApplyOutcome) => {
85
+ const streaks = read()
86
+ const stepped = stepStreak(streaks[key], outcome, now())
87
+ if (stepped.next !== streaks[key]) {
88
+ if (stepped.next) streaks[key] = stepped.next
89
+ else delete streaks[key]
90
+ write(streaks)
91
+ }
92
+ return stepped
93
+ }
94
+
95
+ return {
96
+ /** `local dev` starts with these items: the streaks of any other (deleted, renamed), and expired ones, are forgotten. */
97
+ keep(keys: ReadonlySet<string>): void {
98
+ if (!on || (kept && !existsSync(streaksPath))) return
99
+ try {
100
+ write(Object.fromEntries(Object.entries(read()).filter(([key]) => keys.has(key))))
101
+ } catch {
102
+ // Telemetry never fails the watcher.
103
+ }
104
+ },
105
+ item(target: SyncTarget, outcome: ApplyOutcome, trigger: ApplyTrigger, ms: number, failure?: { output: string } | { error: unknown }): void {
106
+ if (!on) return
107
+ try {
108
+ const { attempt, recovered } = step(target.key, outcome)
109
+ const error = !failure
110
+ ? undefined
111
+ : 'output' in failure
112
+ ? errorFields(outputErrorCode(failure.output), applyFailureReason(failure.output), targetNames(target))
113
+ : errorFields(errorCode((failure.error as { code?: unknown })?.code), failure.error instanceof Error ? failure.error.message : String(failure.error), targetNames(target))
114
+ telemetry.record({
115
+ name: 'frontera.cli.apply',
116
+ severity: SEVERITY[outcome],
117
+ attributes: {
118
+ 'frontera.item_kind': target.kind,
119
+ 'frontera.item_hash': telemetry.hash(project.id, target.key),
120
+ 'frontera.outcome': outcome,
121
+ 'frontera.attempt': attempt,
122
+ 'frontera.trigger': trigger,
123
+ duration_ms: Math.round(ms),
124
+ 'frontera.project.hash': projectHash,
125
+ ...(recovered ? { 'frontera.recovered_after_attempts': recovered.attempts, 'frontera.recovered_after_ms': recovered.ms } : {}),
126
+ },
127
+ ...(error ? { error } : {}),
128
+ })
129
+ } catch {
130
+ // Telemetry never fails an apply.
131
+ }
132
+ },
133
+ round(round: { trigger: ApplyTrigger; queued: number; applied: number; failed: number; ms: number }): void {
134
+ if (!on) return
135
+ telemetry.record({
136
+ name: 'frontera.cli.apply_round',
137
+ severity: round.failed ? 'ERROR' : 'INFO',
138
+ attributes: { 'frontera.trigger': round.trigger, 'frontera.queued': round.queued, 'frontera.applied': round.applied, 'frontera.failed': round.failed, duration_ms: Math.round(round.ms), 'frontera.project.hash': projectHash },
139
+ })
140
+ },
141
+ }
142
+ }
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+
6
+ import pkg from '../../package.json' with { type: 'json' }
7
+ import { findSolutionRoot, localProjectAt, PROJECT_FILES } from '../local/local-project'
8
+ import { forgeSessionOf } from '../provenance'
9
+ import { telemetryMode } from './enabled'
10
+ import { failOpen } from './fail-open'
11
+ import { HARNESSES, type ExitWhat, type Harness, type Severity, type Via } from './events'
12
+ import { createRecorder, hostResource, type Recorder } from './recorder'
13
+
14
+ type Env = Record<string, string | undefined>
15
+
16
+ /**
17
+ * The `frontera` process's recorder: made on first use from this process's
18
+ * environment, `service.name` `frontera-cli`, one invocation id per process.
19
+ */
20
+ let recorder: Recorder | null = null
21
+
22
+ export function cliTelemetry(): Recorder {
23
+ recorder ??= failOpen(() =>
24
+ createRecorder({
25
+ mode: telemetryMode(process.env),
26
+ resource: { 'service.name': 'frontera-cli', ...cliVersion(process.env, import.meta.dir), ...hostResource(process.env) },
27
+ context: cliContext(process.env),
28
+ }),
29
+ )
30
+ return recorder
31
+ }
32
+
33
+ /** The version a bug report would name (as `frontera --version` says it), and whether it is a release. */
34
+ export function cliVersion(env: Env, dir: string): { 'service.version': string; 'deployment.environment': 'release' | 'dev' } {
35
+ const stamped = env.FRONTERA_CLI_VERSION?.trim()
36
+ if (stamped) return { 'service.version': stamped, 'deployment.environment': 'release' }
37
+ return dir.includes('/node_modules/') ? { 'service.version': pkg.version, 'deployment.environment': 'release' } : { 'service.version': `${pkg.version}-dev`, 'deployment.environment': 'dev' }
38
+ }
39
+
40
+ const harnessOf = (value: string | null | undefined): Harness | undefined => (HARNESSES as readonly string[]).includes(value ?? '') ? (value as Harness) : undefined
41
+
42
+ export function cliContext(env: Env): { 'frontera.invocation.id': string; 'frontera.session.id'?: string; 'frontera.harness'?: Harness } {
43
+ const session = forgeSessionOf(env)
44
+ const harness = harnessOf(session?.harness)
45
+ return { 'frontera.invocation.id': randomUUID(), ...(session ? { 'frontera.session.id': session.id } : {}), ...(harness ? { 'frontera.harness': harness } : {}) }
46
+ }
47
+
48
+ const truthy = (value: string | undefined) => !!value && !['0', 'false'].includes(value.trim().toLowerCase())
49
+
50
+ /**
51
+ * Who ran this command: a coding agent in a Forge session, a person in a Forge
52
+ * terminal, Forge itself (the runtime and the watcher it starts), CI, or a
53
+ * person at a terminal.
54
+ */
55
+ export function invocationVia(env: Env): Via {
56
+ const session = forgeSessionOf(env)
57
+ const agent = !!harnessOf(session?.harness) || env.CLAUDECODE === '1'
58
+ if (session) return agent ? 'agent' : 'terminal'
59
+ if (env.FORGE_TELEMETRY_OUTBOX?.trim()) return 'forge'
60
+ if (truthy(env.CI) || truthy(env.GITHUB_ACTIONS)) return 'ci'
61
+ return agent ? 'agent' : 'terminal'
62
+ }
63
+
64
+ /** The salted hash of the project `cwd` is in, when it is in one. */
65
+ export function projectHashAt(cwd: string, home: string = homedir()): string | undefined {
66
+ const telemetry = cliTelemetry()
67
+ if (telemetry.mode.mode === 'off') return undefined
68
+ const root = findSolutionRoot(cwd, home)
69
+ const markers = root === resolve(home) ? PROJECT_FILES : [...PROJECT_FILES, '.frontera']
70
+ if (!markers.some((m) => existsSync(join(root, m)))) return undefined
71
+ return telemetry.hash(localProjectAt(root).id)
72
+ }
73
+
74
+ /** The exit code of a command the person stopped with Ctrl+C (128 + SIGINT), as a shell reports it. */
75
+ export const INTERRUPTED = 130
76
+
77
+ /** The exit code of a command something else stopped with SIGTERM (128 + SIGTERM): Forge's time limit, an agent's shell timeout. */
78
+ export const TERMINATED = 143
79
+
80
+ /** INFO for success, WARN for refused (bad input, a conflict, no permission) or stopped (Ctrl+C, SIGTERM), ERROR for failure. */
81
+ export function severityOfExit(code: number): Severity {
82
+ return code === 0 ? 'INFO' : (code >= 2 && code <= 4) || code === INTERRUPTED || code === TERMINATED ? 'WARN' : 'ERROR'
83
+ }
84
+
85
+ /** An end nobody asked for is ERROR; one asked for with Ctrl+C is WARN (stopped by the person); any other asked-for end is INFO. */
86
+ export const severityOfEnd = (end: { expected: boolean; signal?: string | null }): Severity => (!end.expected ? 'ERROR' : end.signal === 'SIGINT' ? 'WARN' : 'INFO')
87
+
88
+ /** Process end of something the CLI watches, never thrown. */
89
+ export function recordExit(what: ExitWhat, end: { startedAt: number; expected: boolean; exitCode?: number | null; signal?: string | null }): void {
90
+ cliTelemetry().record({
91
+ name: 'frontera.cli.process_exit',
92
+ severity: severityOfEnd(end),
93
+ attributes: {
94
+ 'frontera.what': what,
95
+ 'frontera.expected': end.expected,
96
+ duration_ms: Math.max(0, Math.round(Date.now() - end.startedAt)),
97
+ ...(typeof end.exitCode === 'number' ? { 'frontera.exit_code': end.exitCode } : {}),
98
+ ...(end.signal ? { 'frontera.signal': end.signal } : {}),
99
+ },
100
+ })
101
+ cliTelemetry().roll()
102
+ }
@@ -0,0 +1,64 @@
1
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto'
2
+ import { linkSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * This machine's telemetry identity: a random id sent as `frontera.device.id`,
7
+ * and a salt that never leaves it, for hashing project ids and item names.
8
+ * Kept in the outbox folder as `device.json`, made on first use.
9
+ */
10
+ export interface DeviceIdentity {
11
+ id: string
12
+ salt: string
13
+ }
14
+
15
+ export const DEVICE_FILE = 'device.json'
16
+
17
+ function read(path: string): DeviceIdentity | null {
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<DeviceIdentity>
20
+ return typeof parsed.id === 'string' && parsed.id && typeof parsed.salt === 'string' && parsed.salt ? { id: parsed.id, salt: parsed.salt } : null
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ export function freshIdentity(): DeviceIdentity {
27
+ return { id: randomUUID(), salt: randomBytes(32).toString('hex') }
28
+ }
29
+
30
+ /**
31
+ * Read `device.json`, or make it. Two first runs at once agree on one file: each
32
+ * writes its own temporary file whole, and only the first link to the real name
33
+ * wins; the other reads the winner's. A corrupt file is replaced. The folder
34
+ * must exist (Forge makes it while telemetry is on): this throws without it.
35
+ */
36
+ export function deviceIdentity(dir: string): DeviceIdentity {
37
+ const path = join(dir, DEVICE_FILE)
38
+ const existing = read(path)
39
+ if (existing) return existing
40
+ const made = freshIdentity()
41
+ const tmp = join(dir, `.device-${process.pid}-${randomUUID()}.tmp`)
42
+ writeFileSync(tmp, `${JSON.stringify(made)}\n`, { mode: 0o600 })
43
+ try {
44
+ linkSync(tmp, path)
45
+ return made
46
+ } catch (err) {
47
+ const raced = (err as NodeJS.ErrnoException).code === 'EEXIST' ? read(path) : null
48
+ if (raced) return raced
49
+ // A corrupt file, or a filesystem without hard links: replaced whole.
50
+ renameSync(tmp, path)
51
+ return made
52
+ } finally {
53
+ try {
54
+ unlinkSync(tmp)
55
+ } catch {
56
+ // Renamed into place.
57
+ }
58
+ }
59
+ }
60
+
61
+ /** A salted hash of `parts` (a project id; a project id and an item key): 16 hex characters, the same on this machine every time. */
62
+ export function saltedHash(salt: string, ...parts: string[]): string {
63
+ return createHmac('sha256', salt).update(parts.join('\0')).digest('hex').slice(0, 16)
64
+ }
@@ -0,0 +1,49 @@
1
+ import { isAbsolute } from 'node:path'
2
+
3
+ type Env = Record<string, string | undefined>
4
+
5
+ /**
6
+ * Whether this process records telemetry, and why: the rule in the design's
7
+ * "Who sends". Only Forge puts `FORGE_TELEMETRY_OUTBOX` in a process's
8
+ * environment, so the same CLI in any other terminal writes nothing.
9
+ * `FRONTERA_TELEMETRY=log` prints events to stderr and writes nothing, in or
10
+ * out of Forge, so anyone can see what would be sent.
11
+ */
12
+ export type TelemetryMode =
13
+ | { mode: 'send'; reason: 'forge'; outbox: string }
14
+ | { mode: 'log'; reason: 'log' }
15
+ | { mode: 'off'; reason: 'do_not_track' | 'switched_off' | 'test_runner' | 'outbox_not_absolute' | 'not_in_forge' }
16
+
17
+ export const REASON_TEXT: Record<TelemetryMode['reason'], string> = {
18
+ forge: 'started by Forge (FORGE_TELEMETRY_OUTBOX is set): events are written to the outbox, and Forge sends them to the Frontera team',
19
+ log: 'FRONTERA_TELEMETRY=log: events are printed to stderr, nothing is written or sent',
20
+ do_not_track: 'DO_NOT_TRACK is set: nothing is recorded',
21
+ switched_off: 'FRONTERA_TELEMETRY switches it off: nothing is recorded',
22
+ test_runner: 'running under a test runner (NODE_ENV=test): nothing is recorded',
23
+ outbox_not_absolute: 'FORGE_TELEMETRY_OUTBOX is not an absolute path: nothing is recorded',
24
+ not_in_forge: 'not started by Forge (FORGE_TELEMETRY_OUTBOX is not set): nothing is recorded or sent',
25
+ }
26
+
27
+ const OFF_WORDS = new Set(['0', 'false', 'off', 'no'])
28
+
29
+ /**
30
+ * Set by Forge on the `frontera` calls it makes on its own schedule (a screen's
31
+ * poll, a timed fetch): such a call records its `frontera.cli.command` only
32
+ * when it fails, so a poll every minute does not drown what people and agents run.
33
+ */
34
+ export const BACKGROUND_ENV = 'FORGE_TELEMETRY_BACKGROUND'
35
+
36
+ export const isBackground = (env: Env): boolean => env[BACKGROUND_ENV]?.trim() === '1'
37
+
38
+ export function telemetryMode(env: Env): TelemetryMode {
39
+ const setting = env.FRONTERA_TELEMETRY?.trim().toLowerCase() ?? ''
40
+ const dnt = env.DO_NOT_TRACK?.trim().toLowerCase() ?? ''
41
+ if (dnt && !OFF_WORDS.has(dnt)) return { mode: 'off', reason: 'do_not_track' }
42
+ if (OFF_WORDS.has(setting)) return { mode: 'off', reason: 'switched_off' }
43
+ if (setting === 'log') return { mode: 'log', reason: 'log' }
44
+ if (env.NODE_ENV === 'test') return { mode: 'off', reason: 'test_runner' }
45
+ const outbox = env.FORGE_TELEMETRY_OUTBOX?.trim()
46
+ if (!outbox) return { mode: 'off', reason: 'not_in_forge' }
47
+ if (!isAbsolute(outbox)) return { mode: 'off', reason: 'outbox_not_absolute' }
48
+ return { mode: 'send', reason: 'forge', outbox }
49
+ }