@miphamai/cli 0.81.5 → 0.81.7

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.
Files changed (51) hide show
  1. package/README.md +9 -9
  2. package/bin/daemon.ts +7 -32
  3. package/bin/mipham.ts +43 -29
  4. package/package.json +5 -2
  5. package/skills/standard/mipham-code-setup.SKILL.md +3 -3
  6. package/src/agent/sub-agent.ts +12 -1
  7. package/src/commands/project.ts +92 -12
  8. package/src/config/keys-manager.ts +3 -3
  9. package/src/config/loader.ts +82 -1
  10. package/src/core/context.ts +10 -2
  11. package/src/core/engine.ts +32 -4
  12. package/src/core/metrics.ts +8 -0
  13. package/src/core/paths.ts +79 -0
  14. package/src/core/permission-rules.ts +145 -13
  15. package/src/core/permission.ts +3 -0
  16. package/src/core/session-log.ts +11 -2
  17. package/src/daemon/engine-capabilities.ts +131 -0
  18. package/src/daemon/index.ts +4 -1
  19. package/src/daemon/launch.ts +287 -0
  20. package/src/daemon/remote-engine.ts +5 -0
  21. package/src/daemon/server.ts +9 -0
  22. package/src/daemon/session-worker.ts +7 -4
  23. package/src/i18n-core/locales/en-US.json +6 -7
  24. package/src/i18n-core/locales/zh-CN.json +6 -7
  25. package/src/index.tsx +79 -0
  26. package/src/mcp/client.ts +109 -8
  27. package/src/providers/anthropic.ts +2 -0
  28. package/src/shared/package-info.ts +1 -1
  29. package/src/shared/types.ts +15 -0
  30. package/src/skills/bundled-skills.ts +1 -1
  31. package/src/telemetry/consent.ts +209 -0
  32. package/src/telemetry/crash.ts +197 -0
  33. package/src/telemetry/endpoint.ts +82 -0
  34. package/src/telemetry/index.ts +153 -0
  35. package/src/telemetry/payload.ts +141 -0
  36. package/src/telemetry/queue.ts +95 -0
  37. package/src/telemetry/redact.ts +127 -0
  38. package/src/telemetry/transport.ts +81 -0
  39. package/src/tools/agent/workflow.ts +11 -4
  40. package/src/tools/exec/bash.ts +6 -4
  41. package/src/tools/exec/enter-worktree.ts +6 -5
  42. package/src/tools/exec/exit-worktree.ts +10 -5
  43. package/src/tools/exec/git.ts +18 -8
  44. package/src/tools/system/config.ts +3 -3
  45. package/src/ui/app.tsx +47 -11
  46. package/src/ui/commands.ts +159 -34
  47. package/src/workflow/primitives/agent.ts +4 -2
  48. package/src/core/task-runner-tasks.json +0 -14
  49. package/src/core/task-runner.ts +0 -163
  50. package/src/skills/mipham/runtime.ts +0 -66
  51. package/src/skills/standard/runtime.ts +0 -62
@@ -0,0 +1,197 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { PACKAGE_VERSION } from '../shared/package-info'
3
+ import type { QueuedEvent } from './queue'
4
+ import { hashMessage, redactStack, runtimeTag } from './redact'
5
+ import { SCHEMA_VERSION } from './payload'
6
+
7
+ /**
8
+ * Crash capture.
9
+ *
10
+ * Installing `uncaughtException` / `unhandledRejection` listeners is a
11
+ * **dangerous act**, not a bookkeeping one: once a listener exists, Node no
12
+ * longer applies its own default handling. The default is "print the error,
13
+ * exit 1". A handler that merely records the error would therefore turn every
14
+ * crash into a **silent hang** — the process keeps running with a broken
15
+ * state and no diagnostic.
16
+ *
17
+ * So the contract enforced below, and locked by tests, is:
18
+ * 1. write the original stack to stderr — the user must still see the crash;
19
+ * 2. record a redacted copy for the payload;
20
+ * 3. terminate with a non-zero code, so exit paths (`process.on('exit')`)
21
+ * run and the session is reported as crashed.
22
+ *
23
+ * There were no such listeners anywhere in the repo before this module.
24
+ */
25
+
26
+ export type CrashOrigin = 'uncaughtException' | 'unhandledRejection' | 'render'
27
+
28
+ export interface CrashRecord {
29
+ errorName: string
30
+ messageHash: string
31
+ /**
32
+ * Redacted frames, **local to this process** — never on the wire.
33
+ *
34
+ * Until schema v2 the payload carried these; the collector discarded them on
35
+ * arrival (a frame string has nowhere to live in dimensional aggregates), so
36
+ * v2 stopped sending them. They are still redacted here rather than dropped
37
+ * raw: this record is what a local diagnostic reads, and the redaction
38
+ * guarantee must not depend on which consumer is asking.
39
+ */
40
+ stackFrames: string[]
41
+ frameCount: number
42
+ origin: CrashOrigin
43
+ occurredAt: string
44
+ }
45
+
46
+ /** Sinks, injected so tests can observe termination without killing the runner. */
47
+ export interface CrashSinks {
48
+ writeStderr: (text: string) => void
49
+ exit: (code: number) => void
50
+ }
51
+
52
+ const defaultSinks: CrashSinks = {
53
+ writeStderr: (text) => {
54
+ process.stderr.write(text)
55
+ },
56
+ exit: (code) => {
57
+ process.exit(code)
58
+ },
59
+ }
60
+
61
+ let lastCrash: CrashRecord | null = null
62
+ let installed = false
63
+
64
+ /**
65
+ * Our own listeners, kept by reference.
66
+ *
67
+ * `resetCrashState` must remove exactly these — `removeAllListeners` would also
68
+ * tear out the test runner's own handlers, which is how a test seam turns into
69
+ * a silent loss of crash reporting everywhere else.
70
+ */
71
+ let onUncaught: ((err: unknown) => void) | null = null
72
+ let onRejection: ((reason: unknown) => void) | null = null
73
+
74
+ /** Normalise anything thrown into an Error, without ever throwing itself. */
75
+ function toError(thrown: unknown): Error {
76
+ if (thrown instanceof Error) return thrown
77
+ if (typeof thrown === 'string') return new Error(thrown)
78
+ try {
79
+ return new Error(JSON.stringify(thrown))
80
+ } catch {
81
+ return new Error(String(thrown))
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Record a crash without terminating.
87
+ *
88
+ * Split out from `handleFatal` for the one caller that must survive the error:
89
+ * React's error boundary catches a render failure and keeps the app running, so
90
+ * there is nothing to terminate. Failures here are swallowed — a diagnostic
91
+ * must never be more fragile than the code it is diagnosing.
92
+ */
93
+ export function recordCrash(thrown: unknown, origin: CrashOrigin, now: Date = new Date()): void {
94
+ try {
95
+ const error = toError(thrown)
96
+ const { frames, frameCount } = redactStack(error.stack ?? '')
97
+ lastCrash = {
98
+ errorName: error.name,
99
+ messageHash: hashMessage(error.message),
100
+ stackFrames: frames,
101
+ frameCount,
102
+ origin,
103
+ occurredAt: now.toISOString(),
104
+ }
105
+ } catch {
106
+ /* capture is best-effort */
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Record and terminate. Exported so the test can drive it directly — a test
112
+ * that had to trigger a real uncaught exception would take the runner down
113
+ * with it.
114
+ */
115
+ export function handleFatal(
116
+ thrown: unknown,
117
+ origin: CrashOrigin,
118
+ sinks: CrashSinks = defaultSinks,
119
+ now: Date = new Date(),
120
+ ): void {
121
+ const error = toError(thrown)
122
+
123
+ // 1. Preserve the observable default behaviour: the user sees the crash.
124
+ // Written verbatim — this goes to the user's terminal, not the wire.
125
+ try {
126
+ sinks.writeStderr(`\n${error.stack ?? `${error.name}: ${error.message}`}\n`)
127
+ } catch {
128
+ /* a broken stderr must not block termination */
129
+ }
130
+
131
+ // 2. Record the redacted copy that may leave the machine.
132
+ recordCrash(error, origin, now)
133
+
134
+ // 3. Terminate. Never fall through — that is the silent-hang bug.
135
+ sinks.exit(1)
136
+ }
137
+
138
+ /**
139
+ * Install the listeners once. Idempotent: a second call is a no-op, so a
140
+ * double `initTelemetry()` cannot stack two handlers that each call `exit`.
141
+ */
142
+ export function installCrashHandlers(sinks: CrashSinks = defaultSinks): void {
143
+ if (installed) return
144
+ installed = true
145
+
146
+ onUncaught = (err: unknown) => handleFatal(err, 'uncaughtException', sinks)
147
+ onRejection = (reason: unknown) => handleFatal(reason, 'unhandledRejection', sinks)
148
+
149
+ process.on('uncaughtException', onUncaught)
150
+ process.on('unhandledRejection', onRejection)
151
+ }
152
+
153
+ /** Whether this session crashed — reported in the session payload. */
154
+ export function hasCrashed(): boolean {
155
+ return lastCrash !== null
156
+ }
157
+
158
+ export function getLastCrash(): CrashRecord | null {
159
+ return lastCrash
160
+ }
161
+
162
+ /** Test seam: clear recorded state and allow re-installation. */
163
+ export function resetCrashState(): void {
164
+ lastCrash = null
165
+ installed = false
166
+ if (onUncaught) process.off('uncaughtException', onUncaught)
167
+ if (onRejection) process.off('unhandledRejection', onRejection)
168
+ onUncaught = null
169
+ onRejection = null
170
+ }
171
+
172
+ /**
173
+ * The `crash` event. Holds a message *digest*, never the message text: error
174
+ * messages routinely embed paths and user data.
175
+ *
176
+ * No `stackFrames` since schema v2 — see `SCHEMA_VERSION` in `payload.ts` and
177
+ * the `stackFrames` note on `CrashRecord`.
178
+ */
179
+ export function buildCrashEvent(installId: string): QueuedEvent | null {
180
+ if (!lastCrash) return null
181
+ return {
182
+ id: randomUUID(),
183
+ kind: 'crash',
184
+ payload: {
185
+ installId,
186
+ schemaVersion: SCHEMA_VERSION,
187
+ occurredAt: lastCrash.occurredAt,
188
+ appVersion: PACKAGE_VERSION,
189
+ runtime: runtimeTag(),
190
+ platform: `${process.platform}/${process.arch}`,
191
+ errorName: lastCrash.errorName,
192
+ messageHash: lastCrash.messageHash,
193
+ frameCount: lastCrash.frameCount,
194
+ origin: lastCrash.origin,
195
+ },
196
+ }
197
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Where telemetry is sent.
3
+ *
4
+ * This module is the source of truth for the *destination contract*: the path
5
+ * below has to match the `location =` block in
6
+ * `apps/telemetry/deploy/nginx/log.onemipham.com.conf`. A drift between the two
7
+ * is not a loud failure — every event gets a 404, the client treats 4xx as
8
+ * "permanently unacceptable" and deletes it, and telemetry quietly becomes a
9
+ * no-op for everyone. `apps/cli/test/integrity/telemetry-contract.test.ts` is
10
+ * the mechanical defence against that, which is why the constant lives in a
11
+ * module of its own: the test needs to import exactly one file.
12
+ */
13
+
14
+ /**
15
+ * The hosted receiver. Public, write-only, unauthenticated: the CLI cannot hold
16
+ * a secret (it ships to npm as Apache-2.0 source), so this address is designed
17
+ * to be useless to anyone who finds it — aggregate dimensions only, no readback.
18
+ */
19
+ export const OFFICIAL_TELEMETRY_ENDPOINT = 'https://log.onemipham.com/v1/events'
20
+
21
+ /**
22
+ * Opt in *and* send nowhere.
23
+ *
24
+ * Without this there is no way to express that state. Resolution is
25
+ * `env || user || default` and an empty string is falsy, so `endpoint: ""`
26
+ * falls *through* to the next tier instead of clearing the destination. Before
27
+ * T1b the default was empty and the two were indistinguishable; now the default
28
+ * is a real URL, so an empty override would silently start sending. `none`
29
+ * keeps the capability: self-hosted installs and internal-network audits that
30
+ * want telemetry recorded locally but nothing leaving the machine.
31
+ *
32
+ * Exact match only, the same rule as `MIPHAM_TELEMETRY=off`.
33
+ */
34
+ export const NO_ENDPOINT = 'none'
35
+
36
+ /**
37
+ * Which tier supplied the destination.
38
+ *
39
+ * `off` is not in this union: it is not a destination that was resolved at all,
40
+ * it is the hard kill switch firing before resolution. `TelemetryConsent`
41
+ * widens the type for that case.
42
+ */
43
+ export type EndpointSource = 'env' | 'user' | 'default'
44
+
45
+ export interface ResolvedEndpoint {
46
+ /** Destination. Empty means nothing is ever sent. */
47
+ endpoint: string
48
+ source: EndpointSource
49
+ }
50
+
51
+ /**
52
+ * Resolve the destination, first match wins:
53
+ *
54
+ * 1. `MIPHAM_TELEMETRY_ENDPOINT`
55
+ * 2. user `settings.json` → `telemetry.endpoint`
56
+ * 3. {@link OFFICIAL_TELEMETRY_ENDPOINT}
57
+ *
58
+ * {@link NO_ENDPOINT} at any winning tier resolves to an empty destination, and
59
+ * still reports the tier that supplied it — "which tier decided" and "did it
60
+ * decide to send" are separate questions, and `/telemetry status` answers both.
61
+ */
62
+ export function resolveEndpoint(
63
+ userEndpoint: string | undefined,
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ ): ResolvedEndpoint {
66
+ const fromEnv = env.MIPHAM_TELEMETRY_ENDPOINT
67
+ const raw = fromEnv || userEndpoint || OFFICIAL_TELEMETRY_ENDPOINT
68
+ return {
69
+ endpoint: raw === NO_ENDPOINT ? '' : raw,
70
+ source: fromEnv ? 'env' : userEndpoint ? 'user' : 'default',
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Host of the official receiver, for user-facing copy.
76
+ *
77
+ * Derived rather than retyped: the first-run prompt names the destination, and
78
+ * a hard-coded second copy of it would keep naming the old host after a move.
79
+ */
80
+ export function officialEndpointHost(): string {
81
+ return new URL(OFFICIAL_TELEMETRY_ENDPOINT).host
82
+ }
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { getMetrics } from '../core/metrics'
3
+ import { resolveTelemetry, getOrCreateInstallId, type TelemetryConsent } from './consent'
4
+ import { enqueueSync } from './queue'
5
+ import { buildSessionEvent } from './payload'
6
+ import { buildCrashEvent, hasCrashed, installCrashHandlers } from './crash'
7
+ import { flushQueueInBackground } from './transport'
8
+
9
+ /**
10
+ * Telemetry facade.
11
+ *
12
+ * Data flow — collecting and sending are deliberately decoupled, because
13
+ * `process.on('exit')` cannot await async work:
14
+ *
15
+ * during a session counters accumulate in memory (`getMetrics()`); zero I/O
16
+ * at exit synchronous: whitelist snapshot + session metadata → queue
17
+ * next startup async, fire-and-forget: drain queue → POST → ack or keep
18
+ *
19
+ * Nothing here is a parallel counting system. `getMetrics()` already exists and
20
+ * is the single source for what actually got used; telemetry only *reads* it,
21
+ * and only for the whitelisted families (`payload.ts`).
22
+ */
23
+
24
+ interface TelemetryState {
25
+ consent: TelemetryConsent
26
+ installId: string
27
+ startedAt: number
28
+ /** Guards against a double flush when both SIGINT and a normal exit fire. */
29
+ flushed: boolean
30
+ }
31
+
32
+ let state: TelemetryState | null = null
33
+
34
+ /**
35
+ * The exit flush, kept by reference so `resetTelemetryState` can remove
36
+ * exactly this one — `removeAllListeners('exit')` would detach the runner's
37
+ * own teardown and every other exit path in the process.
38
+ */
39
+ let onExit: (() => void) | null = null
40
+
41
+ /**
42
+ * Start telemetry for this process.
43
+ *
44
+ * Safe to call once at startup. Returns the resolved consent so callers (and
45
+ * `/telemetry status`) can report why telemetry is on or off.
46
+ */
47
+ export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
48
+ const consent = resolveTelemetry(cwd)
49
+ const installId = consent.enabled ? getOrCreateInstallId(cwd) : ''
50
+
51
+ state = { consent, installId, startedAt: Date.now(), flushed: false }
52
+
53
+ // Crash capture is installed unconditionally, even when telemetry is off:
54
+ // it is what keeps a crash from becoming a silent hang. When telemetry is
55
+ // off the record is simply never uploaded.
56
+ installCrashHandlers()
57
+
58
+ // Likewise unconditional. The flush has to be registered while telemetry is
59
+ // still off, because the user can turn it on mid-session (`/telemetry on` →
60
+ // `enableTelemetryNow`) and that session must still be reported. Registering
61
+ // it here rather than in the TUI's own `process.on('exit')` also means the
62
+ // remote-attach and non-interactive paths are covered, not just the one that
63
+ // reaches the TUI setup.
64
+ if (!onExit) {
65
+ onExit = () => shutdownTelemetry()
66
+ process.on('exit', onExit)
67
+ }
68
+
69
+ if (consent.enabled) flushQueueInBackground(consent.endpoint)
70
+
71
+ return consent
72
+ }
73
+
74
+ /**
75
+ * Record a slash-command invocation.
76
+ *
77
+ * Unconditional, like every other counter in the registry — `getMetrics()` is
78
+ * a metrics registry used by the artifact server too, not a telemetry buffer.
79
+ * Whether the snapshot ever leaves the machine is decided at exit.
80
+ */
81
+ export function recordCommand(name: string): void {
82
+ getMetrics().commandCalls.inc({ command_name: name })
83
+ }
84
+
85
+ /** Record a tool invocation that bypassed the engine's `executeTool` funnel. */
86
+ export function recordToolCall(name: string): void {
87
+ getMetrics().toolCalls.inc({ tool_name: name })
88
+ }
89
+
90
+ export function isTelemetryEnabled(): boolean {
91
+ return state?.consent.enabled === true
92
+ }
93
+
94
+ export function getTelemetryConsent(): TelemetryConsent | null {
95
+ return state?.consent ?? null
96
+ }
97
+
98
+ /**
99
+ * Form and queue the session payload. **Synchronous by requirement** — this is
100
+ * called from inside `process.on('exit')`, where an await would never settle.
101
+ *
102
+ * A no-op when telemetry is off, which is what makes "off" mean no collection,
103
+ * no queue file, and no network.
104
+ */
105
+ export function shutdownTelemetry(now: Date = new Date()): void {
106
+ const current = state
107
+ if (!current || current.flushed) return
108
+ current.flushed = true
109
+ if (!current.consent.enabled) return
110
+
111
+ try {
112
+ if (hasCrashed()) {
113
+ const crash = buildCrashEvent(current.installId)
114
+ if (crash) enqueueSync(crash)
115
+ }
116
+ enqueueSync(
117
+ buildSessionEvent({
118
+ installId: current.installId,
119
+ startedAt: current.startedAt,
120
+ endedAt: now.getTime(),
121
+ crashed: hasCrashed(),
122
+ }),
123
+ )
124
+ } catch {
125
+ // A telemetry write must never break the exit path.
126
+ }
127
+ }
128
+
129
+ /** Test seam: drop facade state and unhook the exit flush. */
130
+ export function resetTelemetryState(): void {
131
+ state = null
132
+ if (onExit) process.off('exit', onExit)
133
+ onExit = null
134
+ }
135
+
136
+ /**
137
+ * Persist the user's answer and start reporting immediately, so `/telemetry on`
138
+ * takes effect in the running session rather than at the next launch.
139
+ */
140
+ export function enableTelemetryNow(cwd: string = process.cwd()): void {
141
+ const consent = resolveTelemetry(cwd)
142
+ state = {
143
+ consent,
144
+ installId: getOrCreateInstallId(cwd),
145
+ startedAt: state?.startedAt ?? Date.now(),
146
+ flushed: false,
147
+ }
148
+ }
149
+
150
+ /** A fresh anonymous id, for `/telemetry reset-id`. */
151
+ export function newInstallId(): string {
152
+ return randomUUID()
153
+ }
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { getMetrics } from '../core/metrics'
3
+ import { PACKAGE_VERSION } from '../shared/package-info'
4
+ import type { QueuedEvent } from './queue'
5
+ import { runtimeTag } from './redact'
6
+
7
+ /**
8
+ * Payload construction.
9
+ *
10
+ * The counters are a **whitelist, not a whole-registry dump**. A dump would
11
+ * silently enrol every future counter into the uploaded payload, and a future
12
+ * counter's labels may well carry PII. A whitelist is explicit, can be written
13
+ * into the public data dictionary, and can be locked down by a test asserting
14
+ * the emitted key set is a subset of it.
15
+ *
16
+ * See `docs/telemetry.md` for the public data dictionary.
17
+ */
18
+
19
+ /**
20
+ * Bumped whenever the payload shape changes, so the endpoint can evolve.
21
+ *
22
+ * v2 (this version) = the crash event no longer carries `stackFrames`.
23
+ * The collector never kept them — "dimensional aggregates only" leaves a frame
24
+ * string nowhere to live — so sending them bought ~3 KB per crash of transfer
25
+ * and a privacy surface in exchange for nothing. The frame *count* stays: it is
26
+ * what keeps "this stack was short" distinguishable from "this stack was cut",
27
+ * and it is a single integer.
28
+ *
29
+ * The ordering was not optional: the collector had to accept v1 **before** any
30
+ * client stopped sending frames. Checked in `apps/telemetry/src/schema.ts`,
31
+ * which lists both versions.
32
+ */
33
+ export const SCHEMA_VERSION = 2
34
+
35
+ /**
36
+ * Counter family names cleared for upload, as they appear in the registry.
37
+ * Adding to this list is a privacy decision — it must be matched by a
38
+ * data-dictionary entry in the same commit.
39
+ */
40
+ export const COUNTER_WHITELIST = [
41
+ 'mipham_code_cli_invocations_total',
42
+ 'mipham_code_command_calls_total',
43
+ 'mipham_code_tool_calls_total',
44
+ 'mipham_code_crsi_rule_applications_total',
45
+ 'mipham_code_sis_interceptions_total',
46
+ ] as const
47
+
48
+ /** Maximum length of a label value kept in the payload. */
49
+ const MAX_LABEL_LENGTH = 64
50
+
51
+ /** `Counter.toJSON()` reports `object`; this is the shape it actually returns. */
52
+ interface CounterJson {
53
+ name: string
54
+ series: { labels: string; value: number }[]
55
+ }
56
+
57
+ /**
58
+ * Pull the first `="…"` value out of the registry's formatted label string
59
+ * (e.g. `{tool_name="Bash"}` → `Bash`). Whitelisted families carry at most one
60
+ * label, so first-value is the whole story.
61
+ */
62
+ function firstLabelValue(labels: string): string {
63
+ const match = /="((?:[^"\\]|\\.)*)"/.exec(labels)
64
+ if (!match || match[1] === undefined) return ''
65
+ return match[1]
66
+ .replace(/\\(.)/g, '$1')
67
+ .replace(/[^\x20-\x7e]/g, '')
68
+ .slice(0, MAX_LABEL_LENGTH)
69
+ }
70
+
71
+ /**
72
+ * Snapshot the whitelisted counters into a flat `Record<string, number>`.
73
+ *
74
+ * Keys are the family name with the `mipham_code_` prefix and `_total` suffix
75
+ * stripped, plus the label value when there is one:
76
+ * `mipham_code_cli_invocations_total` → `cli_invocations`
77
+ * `mipham_code_tool_calls_total{tool_name="Bash"}` → `tool_calls.Bash`
78
+ *
79
+ * Label cardinality: `tool_name` is closed by construction — there are only as
80
+ * many tool names as the registry declares. `command_name` is **not**: it comes
81
+ * from user input, so the caller is responsible for collapsing unrecognised
82
+ * names (see `commandLabelFor` in `ui/commands.ts`). `MAX_LABEL_LENGTH` below
83
+ * truncates a value, it does not bound how many keys there are.
84
+ */
85
+ export function snapshotCounters(): Record<string, number> {
86
+ const metrics = getMetrics()
87
+ const { counters } = metrics.toJSON() as { counters: CounterJson[] }
88
+ const out: Record<string, number> = {}
89
+
90
+ for (const counter of counters) {
91
+ const family = counter.name
92
+ if (!(COUNTER_WHITELIST as readonly string[]).includes(family)) continue
93
+
94
+ const short = family.replace(/^mipham_code_/, '').replace(/_total$/, '')
95
+
96
+ for (const series of counter.series) {
97
+ const value = firstLabelValue(series.labels)
98
+ const key = value ? `${short}.${value}` : short
99
+ out[key] = (out[key] ?? 0) + series.value
100
+ }
101
+ }
102
+
103
+ return out
104
+ }
105
+
106
+ /** `darwin/arm64`, `linux/x64`, … — coarse platform identity, no hostname. */
107
+ function platformTag(): string {
108
+ return `${process.platform}/${process.arch}`
109
+ }
110
+
111
+ export interface SessionMeta {
112
+ installId: string
113
+ startedAt: number
114
+ endedAt: number
115
+ crashed: boolean
116
+ }
117
+
118
+ /**
119
+ * The `session` event: one per CLI launch, formed at exit.
120
+ *
121
+ * Contains counters and coarse environment facts only. It never carries
122
+ * conversation content, prompts, file contents, paths, user names, hostnames,
123
+ * API keys, or environment variable values.
124
+ */
125
+ export function buildSessionEvent(meta: SessionMeta, now: Date = new Date()): QueuedEvent {
126
+ return {
127
+ id: randomUUID(),
128
+ kind: 'session',
129
+ payload: {
130
+ installId: meta.installId,
131
+ schemaVersion: SCHEMA_VERSION,
132
+ occurredAt: now.toISOString(),
133
+ appVersion: PACKAGE_VERSION,
134
+ runtime: runtimeTag(),
135
+ platform: platformTag(),
136
+ sessionDurationMs: Math.max(0, meta.endedAt - meta.startedAt),
137
+ crashed: meta.crashed,
138
+ counters: snapshotCounters(),
139
+ },
140
+ }
141
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
+ import { telemetryDir } from './consent'
5
+
6
+ /**
7
+ * Local queue of telemetry events awaiting upload.
8
+ *
9
+ * Every operation here is **synchronous**, and that is a requirement, not a
10
+ * convenience: the session payload is written from inside
11
+ * `process.on('exit')`, which cannot await async work. Every other exit-path
12
+ * write in the repo is synchronous for the same reason (`persistSession` in
13
+ * `index.tsx`, `EffectivenessTracker.persist()`).
14
+ *
15
+ * Sending is therefore decoupled from collecting: events are queued at exit and
16
+ * uploaded on the *next* startup, where async is allowed.
17
+ */
18
+
19
+ /** Entries kept before the oldest are dropped. Bounds disk use on a machine that can never reach an endpoint. */
20
+ export const QUEUE_MAX_ENTRIES = 100
21
+
22
+ export interface QueuedEvent {
23
+ /** Stable per-event id, used to acknowledge a successful upload. */
24
+ id: string
25
+ kind: 'session' | 'crash'
26
+ payload: Record<string, unknown>
27
+ }
28
+
29
+ export function queuePath(): string {
30
+ return join(telemetryDir(), 'queue.jsonl')
31
+ }
32
+
33
+ /**
34
+ * Read the queue. A missing file is an empty queue; an unparsable *line* is
35
+ * skipped rather than thrown, so one truncated write can never wedge every
36
+ * future session.
37
+ */
38
+ export function readQueue(): QueuedEvent[] {
39
+ const path = queuePath()
40
+ if (!existsSync(path)) return []
41
+
42
+ let raw: string
43
+ try {
44
+ raw = readFileSync(path, 'utf-8')
45
+ } catch {
46
+ return []
47
+ }
48
+
49
+ const events: QueuedEvent[] = []
50
+ for (const line of raw.split('\n')) {
51
+ const trimmed = line.trim()
52
+ if (!trimmed) continue
53
+ try {
54
+ const parsed = JSON.parse(trimmed) as QueuedEvent
55
+ if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') events.push(parsed)
56
+ } catch {
57
+ /* skip the damaged line, keep the rest */
58
+ }
59
+ }
60
+ return events
61
+ }
62
+
63
+ /**
64
+ * Replace the queue wholesale, atomically.
65
+ *
66
+ * Capacity is enforced by dropping the *oldest* entries — the newest events
67
+ * describe the current version, which is the version worth hearing about.
68
+ * Silent: a queue write must never surface as a user-visible error, and must
69
+ * never take down an exit path.
70
+ */
71
+ export function writeQueue(events: QueuedEvent[]): void {
72
+ try {
73
+ const kept = events.length > QUEUE_MAX_ENTRIES ? events.slice(-QUEUE_MAX_ENTRIES) : events
74
+ const body = kept.map((e) => JSON.stringify(e)).join('\n')
75
+ // atomicWriteFileSync does not create parents (same as the settings writer,
76
+ // which mkdirs first) — without this the queue silently never materialises
77
+ // on a fresh machine, where ~/.mipham/telemetry/ does not exist yet.
78
+ mkdirSync(telemetryDir(), { recursive: true })
79
+ atomicWriteFileSync(queuePath(), body.length > 0 ? body + '\n' : '', { mode: 0o600 })
80
+ } catch {
81
+ /* best-effort by design */
82
+ }
83
+ }
84
+
85
+ /** Append one event, dropping the oldest if the queue is at capacity. */
86
+ export function enqueueSync(event: QueuedEvent): void {
87
+ writeQueue([...readQueue(), event])
88
+ }
89
+
90
+ /** Drop events whose ids are no longer wanted (i.e. they uploaded successfully). */
91
+ export function ackQueue(ids: Iterable<string>): void {
92
+ const done = new Set(ids)
93
+ if (done.size === 0) return
94
+ writeQueue(readQueue().filter((e) => !done.has(e.id)))
95
+ }