@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.
- package/README.md +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +3 -3
- package/src/config/loader.ts +82 -1
- package/src/core/context.ts +10 -2
- package/src/core/engine.ts +32 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +145 -13
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +11 -2
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +5 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +7 -4
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +79 -0
- package/src/mcp/client.ts +109 -8
- package/src/providers/anthropic.ts +2 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +15 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/exec/bash.ts +6 -4
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +18 -8
- package/src/tools/system/config.ts +3 -3
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +159 -34
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- 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
|
+
}
|