@frontera-sdk/cli 1.53.20 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.20",
3
+ "version": "1.53.22",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.20",
44
- "@frontera-sdk/functions": "1.53.20",
43
+ "@frontera-sdk/core": "1.53.22",
44
+ "@frontera-sdk/functions": "1.53.22",
45
45
  "ai": "^6.0.319",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.20",
52
+ "@frontera-sdk/forge-contracts": "1.53.22",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -26,6 +26,7 @@ import { keepDevWrittenFiles, leftLine, putBackLine } from '../../local/local-ap
26
26
  import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
27
27
  import { ensureProjectApp, requireProjectFrom } from './shared'
28
28
  import { flagBool, flagString, type Command, type CommandContext, type CommandResult } from '../types'
29
+ import { recordExit } from '../../telemetry/cli-telemetry'
29
30
 
30
31
  const DEFAULT_PORT = 3000
31
32
  const READY_TIMEOUT_MS = 30_000
@@ -264,14 +265,20 @@ export const appDev: Command = {
264
265
  throw error
265
266
  }
266
267
 
268
+ const childStarted = Date.now()
267
269
  let stopped = false
270
+ // The signal that stopped this command, for `frontera.cli.process_exit`: an agent's time limit ends it with SIGTERM.
271
+ let received: string | null = null
268
272
  const stop = () => {
269
273
  if (stopped) return
270
274
  stopped = true
271
275
  child.kill('SIGTERM')
272
276
  broker?.server.stop(true)
273
277
  }
274
- const onSignal = () => stop()
278
+ const onSignal = (signal: NodeJS.Signals) => {
279
+ received ??= signal
280
+ stop()
281
+ }
275
282
  process.on('SIGINT', onSignal)
276
283
  process.on('SIGTERM', onSignal)
277
284
  // Started by an MCP session (`app_dev`, `app_render`) or Forge (its Start): it stops when that process ends,
@@ -316,6 +323,7 @@ export const appDev: Command = {
316
323
  text: `Stopped ${app.slug} local development server.`,
317
324
  }
318
325
  } finally {
326
+ const asked = stopped
319
327
  unwatch?.()
320
328
  process.off('SIGINT', onSignal)
321
329
  process.off('SIGTERM', onSignal)
@@ -324,6 +332,7 @@ export const appDev: Command = {
324
332
  // Once the framework's server has exited (or after a while, whatever it does), what it wrote goes back.
325
333
  // Unref'd: a pending wait would hold this process open for the whole STOP_WAIT_MS after the server exited.
326
334
  await Promise.race([child.exited, new Promise((resolve) => setTimeout(resolve, STOP_WAIT_MS).unref())])
335
+ recordExit('app_dev', { startedAt: childStarted, expected: asked, exitCode: child.exitCode, signal: received ?? child.signalCode })
327
336
  const { putBack, left } = kept.atStop()
328
337
  for (const line of [putBackLine(putBack, 'stopped'), leftLine(left)]) if (line) ctx.output.note(`[dev] ${line}`)
329
338
  if (aside) ctx.output.note(`[dev] ${aside.restore()}`)
@@ -68,6 +68,9 @@ import { failedForRuntime, RUNTIME_DOWN_ERROR, RUNTIME_STOPPED_LINE, runtimeAnsw
68
68
  import { noticeLines, projectNotices } from '../../local/local-project-notices'
69
69
  import { refreshVendoredSdk, vendoredSdkState } from '../../local/local-vendored-sdk'
70
70
  import { flagBool, type Command, type CommandContext } from '../types'
71
+ import { applyEvents } from '../../telemetry/apply-events'
72
+ import { recordExit } from '../../telemetry/cli-telemetry'
73
+ import type { ApplyTrigger } from '../../telemetry/events'
71
74
  import { isSharedFunctionFile } from '../../local/local-function-shared'
72
75
  import { applyApp } from '../../local/local-apps'
73
76
  import { applyBrand, brandUpload } from '../../local/local-brand'
@@ -86,6 +89,15 @@ import { actionFailureLines, dedupeLines, hasIndexedPaths, nameIndexedPaths, ref
86
89
 
87
90
  const DEBOUNCE_MS = 300
88
91
 
92
+ /** How Activity words what started a round; a round started by saved files names them (`filesChangedTrigger`). */
93
+ const TRIGGER_WORDS: Record<Exclude<ApplyTrigger, 'files_changed'>, string> = {
94
+ once: 'frontera local dev --once',
95
+ runtime_started: 'runtime started',
96
+ dev_started: 'frontera local dev started',
97
+ runtime_answering: 'the runtime is answering again',
98
+ restore: 'the database was restored to its baseline',
99
+ }
100
+
89
101
  /** How many Blueprint artifacts one apply may leave off for the runtime's refusals before it says the Blueprint failed. */
90
102
  const MAX_REFUSED = 12
91
103
 
@@ -858,13 +870,14 @@ async function dev(ctx: CommandContext) {
858
870
  let runtimeDown = false
859
871
  // What starts the next round of applies, for Activity: the files queued since the last one, or a reason of its own.
860
872
  const changedFiles = new Set<string>()
861
- let nextTrigger: string | null = null
873
+ let nextTrigger: ApplyTrigger | null = null
874
+ const events = applyEvents(project)
862
875
  /**
863
876
  * The round under way, once it applies something: a round that finds every
864
877
  * queued item unchanged applies nothing, so it never says "Applying" and is
865
878
  * never recorded as an apply.
866
879
  */
867
- let round: { since: string | null; trigger: string; applied: number; failed: number } = { since: null, trigger: '', applied: 0, failed: 0 }
880
+ let round: { since: string | null; cause: ApplyTrigger; trigger: string; queued: number; startedAt: number; applied: number; failed: number } = { since: null, cause: 'files_changed', trigger: '', queued: 0, startedAt: 0, applied: 0, failed: 0 }
868
881
  const markApplying = () => {
869
882
  if (round.since) return
870
883
  round.since = new Date().toISOString()
@@ -936,6 +949,7 @@ async function dev(ctx: CommandContext) {
936
949
  ctx.output.note(` ✕ ${line}`)
937
950
  recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'blocked', needs: [], error: line, hash, at: new Date().toISOString() })
938
951
  failing.set(target.key, { item: describeTarget(target), error: line })
952
+ events.item(target, 'stopped', round.cause, 0)
939
953
  }
940
954
  return
941
955
  }
@@ -955,6 +969,7 @@ async function dev(ctx: CommandContext) {
955
969
  failing.set(target.key, { item: describeTarget(target), error: RUNTIME_DOWN_ERROR })
956
970
  recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'failed', error: RUNTIME_DOWN_ERROR, hash, at: new Date().toISOString() })
957
971
  ctx.output.note(` ✕ ${describeTarget(target)}: ${RUNTIME_STOPPED_LINE}`)
972
+ events.item(target, 'runtime_down', round.cause, Date.now() - started)
958
973
  return
959
974
  }
960
975
  if (!result.ok && target.kind === 'blueprint') result = { ...result, output: await blueprintFailureOutput(project, result.output, serviceUrl) }
@@ -968,6 +983,7 @@ async function dev(ctx: CommandContext) {
968
983
  saveHashes(project, organizationId, applied)
969
984
  failed.delete(target.key)
970
985
  failing.delete(target.key)
986
+ events.item(target, 'applied', round.cause, ms)
971
987
  ctx.output.note(` ✓ ${describeTarget(target)} applied (${ms} ms)`)
972
988
  if (target.kind === 'blueprint') personasDue = true
973
989
  if (target.kind === 'workflow' && promoteAfter.delete(target.key)) {
@@ -1035,6 +1051,7 @@ async function dev(ctx: CommandContext) {
1035
1051
  // "unchanged" afterwards, even when it is restored to what last applied.
1036
1052
  if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
1037
1053
  failed.set(target.key, target)
1054
+ events.item(target, 'failed', round.cause, ms, { output: result.output })
1038
1055
  // A retry that fails the same way is not said again in the same round of changes.
1039
1056
  // A governed Action's checks are each a line to fix: shown whole, not cut to the first seven lines.
1040
1057
  const text = result.failureLines ? result.failureLines.map((l, i) => (i ? ` ${l}` : l)).join('\n') : applyErrorText(result.output)
@@ -1098,7 +1115,8 @@ async function dev(ctx: CommandContext) {
1098
1115
  if (busy) return
1099
1116
  busy = true
1100
1117
  // Why this round runs; "Applying" is said only once an item actually applies (`markApplying`).
1101
- round = { since: null, trigger: nextTrigger ?? filesChangedTrigger(changedFiles), applied: 0, failed: 0 }
1118
+ const cause = nextTrigger ?? 'files_changed'
1119
+ round = { since: null, cause, trigger: cause === 'files_changed' ? filesChangedTrigger(changedFiles) : TRIGGER_WORDS[cause], queued: pending.size, startedAt: Date.now(), applied: 0, failed: 0 }
1102
1120
  nextTrigger = null
1103
1121
  changedFiles.clear()
1104
1122
  said.clear()
@@ -1119,7 +1137,11 @@ async function dev(ctx: CommandContext) {
1119
1137
  waiting.set(next.key, next)
1120
1138
  failing.set(next.key, { item: describeTarget(next), error: RUNTIME_DOWN_ERROR })
1121
1139
  ctx.output.note(` ✕ ${describeTarget(next)}: ${RUNTIME_STOPPED_LINE}`)
1122
- } else ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
1140
+ events.item(next, 'runtime_down', round.cause, 0)
1141
+ } else {
1142
+ ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
1143
+ events.item(next, 'failed', round.cause, 0, { error: err })
1144
+ }
1123
1145
  }
1124
1146
  }
1125
1147
  if (runtimeDown) {
@@ -1166,6 +1188,7 @@ async function dev(ctx: CommandContext) {
1166
1188
  if (round.since) {
1167
1189
  recordApply(project, { since: round.since, finished: new Date().toISOString(), trigger: round.trigger, applied: round.applied, failed: round.failed })
1168
1190
  recordApplying(project, null)
1191
+ events.round({ trigger: round.cause, queued: round.queued, applied: round.applied, failed: round.failed, ms: Date.now() - round.startedAt })
1169
1192
  }
1170
1193
  // A file saved after the queue emptied, before this round ended: its own round.
1171
1194
  if (pending.size && !stopping && !runtimeDown) schedule()
@@ -1181,7 +1204,7 @@ async function dev(ctx: CommandContext) {
1181
1204
  const resumeWaiting = () => {
1182
1205
  if (!waiting.size) return
1183
1206
  ctx.output.note(` the local runtime is answering again: applying the ${waiting.size === 1 ? 'item' : `${waiting.size} items`} it missed`)
1184
- nextTrigger ??= 'the runtime is answering again'
1207
+ nextTrigger ??= 'runtime_answering'
1185
1208
  for (const [key, target] of waiting) {
1186
1209
  pending.set(key, target)
1187
1210
  failing.delete(key)
@@ -1216,7 +1239,7 @@ async function dev(ctx: CommandContext) {
1216
1239
  if (!changed.size) {
1217
1240
  if (appsAwayByRestore(project.root).length) {
1218
1241
  secretsDue = true
1219
- nextTrigger = 'the database was restored to its baseline'
1242
+ nextTrigger = 'restore'
1220
1243
  if (!busy) schedule()
1221
1244
  return
1222
1245
  }
@@ -1225,7 +1248,7 @@ async function dev(ctx: CommandContext) {
1225
1248
  return
1226
1249
  }
1227
1250
  secretsDue = true
1228
- nextTrigger = 'the database was restored to its baseline'
1251
+ nextTrigger = 'restore'
1229
1252
  for (const t of allTargets(project.root)) if (changed.has(t.key)) pending.set(t.key, t)
1230
1253
  ctx.output.note(` the database was restored to its baseline: applying ${changed.size === 1 ? 'one item' : `${changed.size} items`} applied since then again`)
1231
1254
  if (!busy) schedule()
@@ -1375,6 +1398,7 @@ async function dev(ctx: CommandContext) {
1375
1398
  const targets = allTargets(project.root)
1376
1399
  // Outcomes of files that are gone are forgotten, so the home lists what the project holds.
1377
1400
  pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
1401
+ events.keep(new Set(targets.map((t) => t.key)))
1378
1402
  // An Agent, workflow or Function whose file was deleted is taken out of service here, as the files now say.
1379
1403
  await sweepOrphans()
1380
1404
  await clearDeletedBehavior()
@@ -1399,7 +1423,7 @@ async function dev(ctx: CommandContext) {
1399
1423
  for (const target of targets) pending.set(target.key, target)
1400
1424
  // The first round: the runtime just started (Forge starts this watcher with it), or `local dev` was run by hand.
1401
1425
  const startedAt = Date.parse(state.startedAt ?? '')
1402
- nextTrigger = once ? 'frontera local dev --once' : Number.isFinite(startedAt) && Date.now() - startedAt < 5 * 60_000 ? 'runtime started' : 'frontera local dev started'
1426
+ nextTrigger = once ? 'once' : Number.isFinite(startedAt) && Date.now() - startedAt < 5 * 60_000 ? 'runtime_started' : 'dev_started'
1403
1427
  await drain()
1404
1428
  if (unchanged) ctx.output.note(` ${unchanged} unchanged since the last apply (--reapply applies them again)`)
1405
1429
  await sayProjectSecrets()
@@ -1453,17 +1477,18 @@ async function dev(ctx: CommandContext) {
1453
1477
  }, 5_000)
1454
1478
 
1455
1479
  await new Promise<void>((resolve) => {
1456
- const stop = () => {
1480
+ const stop = (signal: 'SIGINT' | 'SIGTERM') => {
1457
1481
  stopping = true
1458
1482
  rmSync(pidFile, { force: true })
1459
1483
  if (timer) clearTimeout(timer)
1460
1484
  clearInterval(poller)
1461
1485
  watcher?.close()
1462
1486
  stopChildren()
1487
+ recordExit('local_dev', { startedAt: performance.timeOrigin, expected: true, signal })
1463
1488
  resolve()
1464
1489
  }
1465
- process.once('SIGINT', stop)
1466
- process.once('SIGTERM', stop)
1490
+ process.once('SIGINT', () => stop('SIGINT'))
1491
+ process.once('SIGTERM', () => stop('SIGTERM'))
1467
1492
  })
1468
1493
  return { data: { applied: applied.size }, text: 'Stopped watching' }
1469
1494
  }
@@ -60,6 +60,7 @@ import { localCommands } from './local/index-commands'
60
60
  import { localSnapshot } from './local/snapshot'
61
61
  import { localSteps } from './local/steps'
62
62
  import { mcpServe } from './mcp/serve'
63
+ import { telemetryStatus } from './telemetry/status'
63
64
 
64
65
  /**
65
66
  * Flags every command accepts. Declared once so a caller never has to wonder
@@ -108,6 +109,7 @@ export const COMMANDS: readonly Command[] = [
108
109
  localSnapshot,
109
110
  localSteps,
110
111
  mcpServe,
112
+ telemetryStatus,
111
113
 
112
114
  appInit,
113
115
  appList,
@@ -228,6 +230,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
228
230
  forge: 'Forge — the local daemon the desktop app talks to',
229
231
  mcp: 'Frontera for coding agents — runtime, docs, Apps, browser',
230
232
  local: 'A runtime on this machine — apply, test and check the project',
233
+ telemetry: 'Usage and error data sent from work run inside Forge',
231
234
  }
232
235
 
233
236
  export function nounSummary(noun: string): string {
@@ -0,0 +1,39 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import { listOutbox } from '../../telemetry/outbox'
4
+ import { REASON_TEXT, telemetryMode } from '../../telemetry/enabled'
5
+ import type { Command } from '../types'
6
+
7
+ /**
8
+ * What this process would do with telemetry, and why. Reads the environment
9
+ * and the outbox folder; writes nothing and opens no connection.
10
+ */
11
+ export const telemetryStatus: Command = {
12
+ meta: {
13
+ noun: 'telemetry',
14
+ verb: 'status',
15
+ args: [],
16
+ flags: {},
17
+ summary: 'Say whether this process would send usage and error data, print it, or record nothing, and why',
18
+ details: [
19
+ 'Only a process Forge starts records anything: Forge names its outbox folder in FORGE_TELEMETRY_OUTBOX, and its own server sends what is there to the Frontera team. Anywhere else the CLI records nothing. FRONTERA_TELEMETRY=0 or DO_NOT_TRACK=1 switches it off; FRONTERA_TELEMETRY=log prints each event to stderr and sends nothing.',
20
+ ],
21
+ examples: ['frontera telemetry status', 'FRONTERA_TELEMETRY=log frontera telemetry status --json'],
22
+ offline: true,
23
+ internal: true,
24
+ },
25
+ async run() {
26
+ const mode = telemetryMode(process.env)
27
+ const outbox = process.env.FORGE_TELEMETRY_OUTBOX?.trim() || null
28
+ const files = outbox ? listOutbox(outbox) : []
29
+ const waiting = { open: 0, closed: 0, sending: 0 }
30
+ for (const f of files) waiting[f.state] += 1
31
+ // Forge removes the folder when its switch goes off; a process started before keeps the variable but writes nothing.
32
+ const now = mode.mode === 'send' && !existsSync(mode.outbox) ? { mode: 'off', reason: 'outbox_gone', why: 'started by Forge, which has switched telemetry off since (the outbox folder is gone): nothing is recorded' } : { mode: mode.mode, reason: mode.reason, why: REASON_TEXT[mode.reason] }
33
+ const where = outbox ? `outbox: ${outbox} (${waiting.closed} waiting to be sent, ${waiting.sending} being sent, ${waiting.open} being written)` : 'outbox: none (FORGE_TELEMETRY_OUTBOX is not set)'
34
+ return {
35
+ data: { ...now, outbox, files: waiting },
36
+ text: [`Telemetry: ${now.mode}`, ` ${now.why}`, ` ${where}`].join('\n'),
37
+ }
38
+ },
39
+ }
@@ -46,6 +46,7 @@ import type { LocalProject } from './local-project'
46
46
  import { startLocalEmbeddings } from './local-embeddings'
47
47
  import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
48
48
  import { recordRuntime } from './local-runtime-history'
49
+ import { recordExit } from '../telemetry/cli-telemetry'
49
50
  import { buildDocs, checkoutDocsStale, copyDocs, customerDocsBundle, IMAGE_DOCS_PATH, writeDocsBuildMarker } from './local-docs'
50
51
  import { keepSchemas, type SchemaSource } from './local-schemas'
51
52
  import { applyModelKeys, ensureBlueprint, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
@@ -139,7 +140,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
139
140
  const running: Running = { children: new Map(), containers: new Set() }
140
141
  let stopping = false
141
142
 
142
- const shutdown = async (code: number) => {
143
+ const shutdown = async (code: number, signal?: NodeJS.Signals) => {
143
144
  if (stopping) return
144
145
  stopping = true
145
146
  state = { ...state, phase: 'stopping' }
@@ -157,10 +158,11 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
157
158
  if (code === 0) recordRuntime(project, { event: 'stopped', at: new Date().toISOString() })
158
159
  state = { ...state, phase: 'stopped', supervisorPid: undefined }
159
160
  save()
161
+ recordExit('runtime', { startedAt: performance.timeOrigin, expected: code === 0, exitCode: code, signal })
160
162
  process.exit(code)
161
163
  }
162
- process.on('SIGTERM', () => void shutdown(0))
163
- process.on('SIGINT', () => void shutdown(0))
164
+ process.on('SIGTERM', () => void shutdown(0, 'SIGTERM'))
165
+ process.on('SIGINT', () => void shutdown(0, 'SIGINT'))
164
166
 
165
167
  const fail = async (name: ComponentName, detail: string) => {
166
168
  set(name, { status: 'failed', detail })
@@ -299,9 +301,11 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
299
301
 
300
302
  // Watch native children: a crash is reported, not hidden.
301
303
  for (const [name, child] of running.children) {
302
- child.on('exit', (code) => {
304
+ const startedAt = Date.parse(state.components.find((c) => c.name === name)?.startedAt ?? '') || Date.now()
305
+ child.on('exit', (code, signal) => {
303
306
  if (stopping) return
304
307
  set(name, { status: 'failed', pid: undefined, detail: `exited with code ${code ?? 'signal'}; see \`frontera local logs ${name}\`` })
308
+ recordExit(name, { startedAt, expected: false, exitCode: code, signal })
305
309
  })
306
310
  }
307
311
 
@@ -311,6 +315,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
311
315
  probe: () => portAnswers(ports.jobs, 3_000),
312
316
  restart: async () => {
313
317
  if (stopping) return
318
+ recordExit('jobs', { startedAt: Date.parse(state.components.find((c) => c.name === 'jobs')?.startedAt ?? '') || performance.timeOrigin, expected: false })
314
319
  const restarted = await restartJobs(project, state, 'it stopped answering, so the supervisor restarted it')
315
320
  set('jobs', { status: 'running', startedAt: new Date().toISOString(), detail: undefined })
316
321
  appendFileSync(logPath(project, 'studio'), `[supervisor] jobs engine restarted in ${restarted.ms} ms${restarted.lost ? `; ${restarted.lost} lost runs marked failed` : ''}${restarted.note ? `; ${restarted.note}` : ''}\n`)
package/src/main.ts CHANGED
@@ -16,6 +16,9 @@ import { remoteFromLocalProject, remoteWords } from './remote-guard'
16
16
  import { createOutput, type OutputMode } from './output'
17
17
  import { readProject } from './project'
18
18
  import { commandScope, unaddressedWorkspaceRefusal } from './scopes'
19
+ import { cliTelemetry, INTERRUPTED, invocationVia, projectHashAt, severityOfExit, TERMINATED } from './telemetry/cli-telemetry'
20
+ import { isBackground } from './telemetry/enabled'
21
+ import { errorCode, errorFields } from './telemetry/redact'
19
22
  import {
20
23
  aliasesFor,
21
24
  describeCommand,
@@ -75,7 +78,70 @@ async function resolveVersion(): Promise<string> {
75
78
  return import.meta.dir.includes('/node_modules/') ? version : `${version}-dev`
76
79
  }
77
80
 
81
+ /**
82
+ * What `frontera.cli.command` says of this process, filled in as dispatch
83
+ * learns it: registered names only, flag names without their values.
84
+ */
85
+ const invocation: { noun: string; verb: string; flags: string[]; names: string[]; cwd: string; failure?: unknown } = { noun: 'unknown', verb: '', flags: [], names: [], cwd: process.cwd() }
86
+
87
+ let recorded = false
88
+
89
+ /** Once per process, when it ends or is stopped with Ctrl+C; never fails or slows the command. */
90
+ function recordCommand(exitCode: number): void {
91
+ if (recorded) return
92
+ recorded = true
93
+ try {
94
+ // Forge's own poll that succeeded says nothing anyone did.
95
+ if (exitCode === 0 && isBackground(process.env)) return
96
+ const telemetry = cliTelemetry()
97
+ if (telemetry.mode.mode === 'off') return
98
+ const { failure } = invocation
99
+ const error = failure === undefined ? undefined : errorFields(errorCode((failure as { code?: unknown })?.code), failure instanceof Error ? failure.message : String(failure), invocation.names)
100
+ const project = projectHashAt(invocation.cwd)
101
+ telemetry.record({
102
+ name: 'frontera.cli.command',
103
+ severity: severityOfExit(exitCode),
104
+ attributes: {
105
+ 'frontera.noun': invocation.noun,
106
+ 'frontera.verb': invocation.verb,
107
+ 'frontera.flags': [...new Set(invocation.flags)].sort().join(','),
108
+ 'frontera.exit_code': exitCode,
109
+ 'frontera.via': invocationVia(process.env),
110
+ duration_ms: Math.round(performance.now()),
111
+ ...(project ? { 'frontera.project.hash': project } : {}),
112
+ },
113
+ ...(error ? { error } : {}),
114
+ })
115
+ telemetry.roll()
116
+ } catch {
117
+ // Telemetry never fails the command.
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Ctrl+C or SIGTERM (Forge's time limit, an agent's shell timeout): recorded
123
+ * as stopped (exit 130 or 143, WARN), with how long it ran, before anything
124
+ * ends. A command with its own handler for the signal (`local dev`, `app dev`,
125
+ * the runtime supervisor, `forge serve`) still stops its own way. For it,
126
+ * SIGTERM is how its owner stops it normally, so only Ctrl+C is recorded as
127
+ * stopped. With no handler, the signal is raised again with none, so the
128
+ * process ends exactly as the default would (the shell says 130 or 143).
129
+ */
130
+ const STOPPED = { SIGINT: INTERRUPTED, SIGTERM: TERMINATED } as const
131
+
132
+ function onStop(signal: keyof typeof STOPPED): () => void {
133
+ const handler = () => {
134
+ const handled = process.listenerCount(signal) > 1
135
+ if (signal === 'SIGINT' || !handled) recordCommand(STOPPED[signal])
136
+ if (handled) return
137
+ process.off(signal, handler)
138
+ process.kill(process.pid, signal)
139
+ }
140
+ return handler
141
+ }
142
+
78
143
  async function main(): Promise<number> {
144
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, onStop(signal))
79
145
  const argv = process.argv.slice(2)
80
146
 
81
147
  /**
@@ -112,6 +178,8 @@ async function main(): Promise<number> {
112
178
  }
113
179
 
114
180
  const remaining = argv.slice(cursor)
181
+ // Until a command is found, every word typed is a name to keep out of an error template.
182
+ invocation.names = remaining.filter((a) => !a.startsWith('-'))
115
183
 
116
184
  // Flags but no command. Showing help beats "unknown command: --api-url",
117
185
  // which reads as a typo in the flag rather than as a missing command.
@@ -126,9 +194,11 @@ async function main(): Promise<number> {
126
194
  // Bare `frontera`, `help`, `--help`, `--version` before anything that could
127
195
  // fail — help has to work with no credential and no project.
128
196
  if (!noun || noun === 'help' || noun === '--help' || noun === '-h') {
197
+ invocation.noun = 'help'
129
198
  return emit(describeCommands(), renderHelp)
130
199
  }
131
200
  if (noun === '--version' || noun === 'version') {
201
+ invocation.noun = 'version'
132
202
  const version = await resolveVersion()
133
203
  return emit({ version }, () => version)
134
204
  }
@@ -136,6 +206,8 @@ async function main(): Promise<number> {
136
206
  // Nouns with no verb (`frontera init`) take their arguments directly.
137
207
  const direct = findCommand(noun, '')
138
208
  const command = direct ?? findCommand(noun, maybeVerb)
209
+ if (nouns(true).includes(noun)) invocation.noun = noun
210
+ if (command) invocation.verb = command.meta.verb
139
211
 
140
212
  // `frontera agent --help`, or a bare `frontera agent`: list the noun's
141
213
  // commands. Without this the CLI's own hints dead-ended, since a reserved
@@ -158,6 +230,8 @@ async function main(): Promise<number> {
158
230
  const args = direct ? [maybeVerb, ...rest].filter((a): a is string => a !== undefined) : rest
159
231
  const spec = flagsFor(command.meta)
160
232
  const { positional, flags, repeated } = parseArgs(args, spec, aliasesFor(command.meta))
233
+ invocation.flags = Object.keys(flags)
234
+ invocation.names = [...positional, ...Object.values(flags).filter((v): v is string => typeof v === 'string')]
161
235
 
162
236
  if (flags.help === true) {
163
237
  return emit(describeCommand(command.meta), () => renderCommandHelp(command.meta))
@@ -185,6 +259,7 @@ async function main(): Promise<number> {
185
259
 
186
260
  const dirFlag = typeof flags.dir === 'string' ? flags.dir : undefined
187
261
  const cwd = dirFlag ?? process.cwd()
262
+ invocation.cwd = cwd
188
263
 
189
264
  /**
190
265
  * A missing argument outranks a missing project directory.
@@ -407,6 +482,7 @@ async function main(): Promise<number> {
407
482
  // rather than each API client rewording its own 401/403.
408
483
  const laneFailure = sessionLaneFailure(command.meta, token, err)
409
484
  const failure = (laneFailure && (await onLocalRuntime(laneFailure, command.meta, typeof flags.dir === 'string' ? flags.dir : process.cwd(), { apiUrl: apiUrlUsed, token }).catch(() => null))) ?? laneFailure ?? err
485
+ invocation.failure = failure
410
486
  return output.fail(personaToken ? redact(failure, personaToken) : failure)
411
487
  }
412
488
  }
@@ -423,6 +499,7 @@ main()
423
499
  process.exitCode = code
424
500
  })
425
501
  .catch((err) => {
502
+ invocation.failure = err
426
503
  // Only reachable for a throw outside the try — an unknown command, argument
427
504
  // parsing, or help rendering. Asked for JSON on the command line, it is JSON
428
505
  // (X10: `trace get --json` was no command, and said so only in prose).
@@ -438,3 +515,4 @@ main()
438
515
  if (err instanceof CliError && err.hint) process.stderr.write(` ${err.hint}\n`)
439
516
  process.exitCode = code === 'USAGE' ? EXIT.USAGE : EXIT.FAILURE
440
517
  })
518
+ .finally(() => recordCommand(Number(process.exitCode ?? 0)))
@@ -138,6 +138,8 @@ export interface EnsureDevServerOptions {
138
138
  owner?: 'mcp' | 'forge' | 'detached'
139
139
  /** The port to listen on. Default: a free one from 3100. */
140
140
  port?: number
141
+ /** Variables for the server's process on top of this one's (Forge's telemetry outbox). */
142
+ env?: Record<string, string>
141
143
  /** Called once when the server this call started ends, however it ends. */
142
144
  onExit?: (exit: DevServerExit) => void
143
145
  }
@@ -291,7 +293,7 @@ export async function ensureDevServer(project: LocalProject, app: AppProjectRef,
291
293
  const from = statSync(log).size
292
294
  const [exe, ...pre] = options.command ?? selfCommand()
293
295
  const owned = options.owner !== 'detached'
294
- const env: NodeJS.ProcessEnv = { ...process.env, ...credential.env, NO_COLOR: '1', ...(credential.token ? { [TOKEN_ON_STDIN_ENV]: '1' } : {}) }
296
+ const env: NodeJS.ProcessEnv = { ...process.env, ...options.env, ...credential.env, NO_COLOR: '1', ...(credential.token ? { [TOKEN_ON_STDIN_ENV]: '1' } : {}) }
295
297
  delete env[DEV_OWNER_ENV]
296
298
  const child = spawn(exe!, [...pre, 'app', 'dev', '--no-open', '--port', String(port)], {
297
299
  cwd: app.root,
@@ -1,5 +1,8 @@
1
1
  import { createInterface } from 'node:readline'
2
2
 
3
+ import { cliTelemetry, projectHashAt } from '../telemetry/cli-telemetry'
4
+ import { errorCode, errorFields, outputErrorCode } from '../telemetry/redact'
5
+
3
6
  /**
4
7
  * The part of the Model Context Protocol `frontera mcp` speaks: JSON-RPC 2.0,
5
8
  * one message per line on stdio, with tools only. Calls run concurrently and
@@ -65,17 +68,45 @@ export async function answerMcpMessage(message: McpMessage, info: McpServerInfo,
65
68
  const input = (message.params?.arguments ?? {}) as Record<string, unknown>
66
69
  const tool = tools.find((t) => t.name === name)
67
70
  if (!tool) return reply(errorResult(`unknown tool "${name}"`))
71
+ const started = performance.now()
72
+ let result: ToolResult
73
+ let thrown: unknown
68
74
  try {
69
- return reply(await tool.run(input))
75
+ result = await tool.run(input)
70
76
  } catch (err) {
71
- return reply(errorResult((err as Error).message))
77
+ thrown = err
78
+ result = errorResult((err as Error).message)
72
79
  }
80
+ const ms = performance.now() - started
81
+ // After the reply is written: telemetry's file work never adds to the tool's latency.
82
+ setImmediate(() => recordToolCall(tool.name, input, result, thrown, ms))
83
+ return reply(result)
73
84
  }
74
85
  default:
75
86
  return { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not found: ${String(message.method)}` } }
76
87
  }
77
88
  }
78
89
 
90
+ /** `frontera.cli.mcp_tool`: the tool, whether it worked, how long it took, and a failure's code and template; never its input or output. */
91
+ function recordToolCall(tool: string, input: Record<string, unknown>, result: ToolResult, thrown: unknown, ms: number): void {
92
+ try {
93
+ const telemetry = cliTelemetry()
94
+ if (telemetry.mode.mode === 'off') return
95
+ const text = result.content.find((c) => c.type === 'text')?.text ?? ''
96
+ const names = Object.values(input).filter((v): v is string => typeof v === 'string')
97
+ const code = thrown === undefined ? outputErrorCode(text) : errorCode((thrown as { code?: unknown })?.code)
98
+ const project = projectHashAt(process.cwd())
99
+ telemetry.record({
100
+ name: 'frontera.cli.mcp_tool',
101
+ severity: result.isError ? 'ERROR' : 'INFO',
102
+ attributes: { 'frontera.tool': tool, 'frontera.ok': !result.isError, duration_ms: Math.round(ms), ...(project ? { 'frontera.project.hash': project } : {}) },
103
+ ...(result.isError ? { error: errorFields(code, text, names) } : {}),
104
+ })
105
+ } catch {
106
+ // Telemetry never fails a tool call.
107
+ }
108
+ }
109
+
79
110
  /** Serve over stdio until stdin closes, then wait for the calls still running. */
80
111
  export async function serveMcp(args: {
81
112
  info: McpServerInfo