@venturewild/workspace 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ // logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
2
+ // the operator channel all read the same set, and a tiny append/rotate/tail
3
+ // helper for the new logs we write ourselves (cli, operator).
4
+ //
5
+ // WHY a registry and not just scattered paths: a brand-new user's install is the
6
+ // riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
7
+ // and "I can't see what happened on their machine" is the #1 un-debuggable
8
+ // failure. Centralising the log locations is what lets `wild-workspace doctor`
9
+ // gather them and the operator channel tail them by name — never by arbitrary
10
+ // path (the tail allowlist is TAILABLE below).
11
+ //
12
+ // NOTE: the supervisor + daemon-supervisor keep writing their logs at the
13
+ // global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
14
+ // are reboot-proven and pinned by tests via an injected globalDir, so we mirror
15
+ // them here for READING rather than moving them. Everything lives under
16
+ // ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
17
+
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+
22
+ // THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
23
+ // defaults. Overridable via env for tests / unusual homes.
24
+ export function globalDir(env = process.env) {
25
+ return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
26
+ }
27
+
28
+ // Logical name → filename. The first three are written by existing components;
29
+ // cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
30
+ export const LOG_FILES = Object.freeze({
31
+ supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
32
+ server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
33
+ daemon: 'daemon.log', // the bmo-sync daemon
34
+ cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
35
+ operator: 'operator.log', // the consented operator channel's audit trail
36
+ });
37
+
38
+ // Logs the operator channel may tail BY NAME — never an arbitrary path.
39
+ export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
40
+
41
+ export function logFile(name, env = process.env) {
42
+ return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
43
+ }
44
+
45
+ export function diagnosticsDir(env = process.env) {
46
+ return path.join(globalDir(env), 'diagnostics');
47
+ }
48
+
49
+ const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
50
+
51
+ // Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
52
+ export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
53
+ try {
54
+ if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
55
+ } catch {
56
+ /* missing / racing rename — nothing to rotate */
57
+ }
58
+ }
59
+
60
+ // Append a timestamped line to a named log; ensures the dir + rotates. Returns
61
+ // the file path. Never throws — logging must not break the caller's path.
62
+ export function appendLine(name, line, env = process.env) {
63
+ const file = logFile(name, env);
64
+ try {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ rotateIfBig(file);
67
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
68
+ } catch {
69
+ /* read-only fs etc. — degrade silently */
70
+ }
71
+ return file;
72
+ }
73
+
74
+ // Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
75
+ // Returns '' when the file is missing/unreadable.
76
+ export function tailFile(file, n = 200) {
77
+ try {
78
+ // Drop the trailing newline so the last line isn't an empty entry.
79
+ const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
80
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
81
+ } catch {
82
+ return '';
83
+ }
84
+ }
85
+
86
+ // All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
87
+ export function listLogs(env = process.env) {
88
+ return TAILABLE.map((name) => {
89
+ const file = logFile(name, env);
90
+ try {
91
+ const st = fs.statSync(file);
92
+ return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
93
+ } catch {
94
+ return { name, file, exists: false, size: null, mtime: null };
95
+ }
96
+ });
97
+ }
@@ -0,0 +1,45 @@
1
+ // Consent for the proactive session + install observability feed (session-reporter.mjs
2
+ // + transcript.mjs).
3
+ //
4
+ // DEFAULT-ON: an absent file means consented — because the disclosure is shown at
5
+ // onboarding, and this is the model the apps users already trust use (they retain
6
+ // sessions by default with an easy off). An explicit opt-out persists here. Kept
7
+ // in its own file (not agent-identity.json) so it's readable at first load —
8
+ // before the agent is even named — and trivial to flip. Mirrors operator.mjs.
9
+ //
10
+ // What "on" actually streams: WHAT happened + install health, never the words
11
+ // (redaction lives in session-reporter.mjs). Off also hard-stops the kill switch
12
+ // path WILD_WORKSPACE_NO_TELEMETRY=1, which disables ALL telemetry regardless.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ export const OBSERVABILITY_VERSION = 1;
18
+
19
+ function consentFile(dataDir) {
20
+ return path.join(dataDir, 'observability.json');
21
+ }
22
+
23
+ export function loadObservabilityConsent(dataDir) {
24
+ try {
25
+ const p = JSON.parse(fs.readFileSync(consentFile(dataDir), 'utf8'));
26
+ return {
27
+ enabled: p.enabled !== false,
28
+ decidedAt: p.decidedAt ? Number(p.decidedAt) : null,
29
+ version: Number(p.version) || OBSERVABILITY_VERSION,
30
+ };
31
+ } catch {
32
+ return { enabled: true, decidedAt: null, version: OBSERVABILITY_VERSION }; // default-on
33
+ }
34
+ }
35
+
36
+ export function setObservabilityConsent(dataDir, enabled) {
37
+ const rec = { enabled: Boolean(enabled), decidedAt: Date.now(), version: OBSERVABILITY_VERSION };
38
+ try {
39
+ fs.mkdirSync(dataDir, { recursive: true });
40
+ fs.writeFileSync(consentFile(dataDir), JSON.stringify(rec, null, 2), { mode: 0o600 });
41
+ } catch {
42
+ /* read-only fs — fall back to in-memory for this run */
43
+ }
44
+ return rec;
45
+ }
@@ -0,0 +1,65 @@
1
+ // Operator channel token — the consented "let the wild-workspace team help with
2
+ // my install" capability (docs/SECURITY.md, docs/user-experience.md §5).
3
+ //
4
+ // SECURITY POSTURE: this channel is OFF by default. It only exists once a token
5
+ // is minted (`wild-workspace operator enable`, an explicit user opt-in). With no
6
+ // token, the operator role is unreachable and every /api/operator/* route 404s.
7
+ // The token is a separate secret from the partner token (so it can be handed to
8
+ // support + revoked independently), persisted 0600, and accepted ONLY in an
9
+ // Authorization header — never a `?t=` query (it must not leak via logs /
10
+ // history / referrer; SECURITY.md S1). Disabling deletes the token.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import crypto from 'node:crypto';
15
+
16
+ export function operatorFile(dataDir) {
17
+ return path.join(dataDir, 'operator.json');
18
+ }
19
+
20
+ // The token, or null when the channel is disabled (the common case).
21
+ export function loadOperatorToken(dataDir) {
22
+ try {
23
+ const parsed = JSON.parse(fs.readFileSync(operatorFile(dataDir), 'utf8'));
24
+ return typeof parsed.operatorToken === 'string' && parsed.operatorToken
25
+ ? parsed.operatorToken
26
+ : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // Turn the channel on. Idempotent by default — returns the existing token if one
33
+ // is already set (so a re-run doesn't invalidate the code the user already
34
+ // shared). Pass { rotate:true } to force a fresh token. Returns the token, or
35
+ // null if it couldn't be persisted.
36
+ export function enableOperator(dataDir, { rotate = false } = {}) {
37
+ const existing = loadOperatorToken(dataDir);
38
+ if (existing && !rotate) return existing;
39
+ const token = crypto.randomBytes(24).toString('base64url');
40
+ try {
41
+ fs.mkdirSync(dataDir, { recursive: true });
42
+ fs.writeFileSync(
43
+ operatorFile(dataDir),
44
+ JSON.stringify({ operatorToken: token, enabledAt: Date.now() }, null, 2),
45
+ { mode: 0o600 },
46
+ );
47
+ } catch {
48
+ return null;
49
+ }
50
+ return token;
51
+ }
52
+
53
+ // Turn the channel off (revoke). Returns true if a token file was removed.
54
+ export function disableOperator(dataDir) {
55
+ try {
56
+ fs.rmSync(operatorFile(dataDir));
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export function operatorStatus(dataDir) {
64
+ return { enabled: Boolean(loadOperatorToken(dataDir)), file: operatorFile(dataDir) };
65
+ }
@@ -0,0 +1,127 @@
1
+ // service.mjs — installs / removes the per-OS, NO-ADMIN autostart entry that
2
+ // launches the WorkspaceSupervisor hidden at login. See docs/always-on-design.md.
3
+ //
4
+ // Windows (proven end-to-end incl. a real reboot, 2026-05-30): writes a tiny VBS
5
+ // that runs `node <cli> service run` with no window, and registers it under
6
+ // HKCU\...\Run (per-user, NO admin / UAC). macOS (LaunchAgent + KeepAlive) and
7
+ // Linux (systemd --user + Restart=always) are designed but not yet implemented —
8
+ // they return a clear "not yet" result so callers degrade gracefully (the user
9
+ // can still run `wild-workspace` manually).
10
+ //
11
+ // All state (the VBS, service.json, the supervisor's lock/logs) lives in the
12
+ // machine-global dir (~/.wild-workspace), NEVER the synced workspace (locked
13
+ // principle #1). Every external touch-point (reg.exe, kill) is an injected seam.
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ const execFileP = promisify(execFile);
22
+
23
+ export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
24
+ export const RUN_VALUE_NAME = 'WildWorkspace';
25
+
26
+ export function globalDir() { return path.join(os.homedir(), '.wild-workspace'); }
27
+
28
+ /** Is per-user autostart implemented for this platform yet? */
29
+ export function isSupported(platform = process.platform) { return platform === 'win32'; }
30
+
31
+ // A VBS that runs `node <cli> service run` with NO window (0 = SW_HIDE,
32
+ // False = don't wait). VBS string literals escape a `"` as `""`.
33
+ export function buildVbs(node, cli) {
34
+ const cmd = `"${node}" "${cli}" service run`;
35
+ const vbsArg = '"' + cmd.replace(/"/g, '""') + '"';
36
+ return [
37
+ "' wild-workspace always-on launcher (generated by `wild-workspace service install`).",
38
+ "' Starts the workspace supervisor hidden at login. Disable via",
39
+ "' `wild-workspace service uninstall` (removes the HKCU\\...\\Run value).",
40
+ `CreateObject("WScript.Shell").Run ${vbsArg}, 0, False`,
41
+ '',
42
+ ].join('\r\n');
43
+ }
44
+
45
+ /** The HKCU\Run value: launch the VBS via the windowless wscript host. */
46
+ export function buildRunValue(vbs) { return `wscript.exe "${vbs}"`; }
47
+
48
+ // --- Windows implementation -------------------------------------------------
49
+
50
+ async function winInstall({ node, cli, workspaceDir, port, version }, { dir, execFileImpl }) {
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ const vbs = path.join(dir, 'launch-hidden.vbs');
53
+ const serviceJson = path.join(dir, 'service.json');
54
+ fs.writeFileSync(vbs, buildVbs(node, cli), 'utf8');
55
+ fs.writeFileSync(
56
+ serviceJson,
57
+ JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
58
+ 'utf8',
59
+ );
60
+ const runValue = buildRunValue(vbs);
61
+ await execFileImpl('reg', ['add', RUN_KEY, '/v', RUN_VALUE_NAME, '/t', 'REG_SZ', '/d', runValue, '/f']);
62
+ return { installed: true, mechanism: 'HKCU\\Run', vbs, runValue, serviceJson };
63
+ }
64
+
65
+ async function winUninstall({ dir, execFileImpl, killImpl }) {
66
+ let removedKey = false;
67
+ try { await execFileImpl('reg', ['delete', RUN_KEY, '/v', RUN_VALUE_NAME, '/f']); removedKey = true; } catch { /* not present */ }
68
+ let stoppedPid = null;
69
+ try {
70
+ const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
71
+ if (pid) { killImpl(pid); stoppedPid = pid; }
72
+ } catch { /* none running */ }
73
+ for (const f of ['launch-hidden.vbs', 'service.json']) { try { fs.unlinkSync(path.join(dir, f)); } catch { /* gone */ } }
74
+ return { uninstalled: true, removedKey, stoppedPid };
75
+ }
76
+
77
+ async function winStatus({ dir, execFileImpl, probeImpl, port }) {
78
+ let installed = false, runValue = null;
79
+ try {
80
+ const { stdout } = await execFileImpl('reg', ['query', RUN_KEY, '/v', RUN_VALUE_NAME]);
81
+ installed = new RegExp(RUN_VALUE_NAME, 'i').test(stdout);
82
+ runValue = (stdout.match(/REG_SZ\s+(.*?)\s*$/m) || [])[1] || null;
83
+ } catch { /* value absent → not installed */ }
84
+ let supervisorPid = null, supervisorAlive = false;
85
+ try { supervisorPid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim()) || null; } catch { /* none */ }
86
+ if (supervisorPid) {
87
+ try { process.kill(supervisorPid, 0); supervisorAlive = true; } catch (e) { supervisorAlive = !!(e && e.code === 'EPERM'); }
88
+ }
89
+ const serverUp = await probeImpl(port);
90
+ return { installed, runValue, supervisorPid, supervisorAlive, serverUp };
91
+ }
92
+
93
+ // --- public API (platform dispatch) ----------------------------------------
94
+
95
+ const unsupported = (platform, key) => ({
96
+ [key]: false,
97
+ supported: false,
98
+ platform,
99
+ message: `always-on autostart for ${platform} is not implemented yet — run \`wild-workspace\` to start manually (see docs/always-on-design.md)`,
100
+ });
101
+
102
+ export async function installService(opts = {}, deps = {}) {
103
+ const platform = deps.platform || process.platform;
104
+ if (platform !== 'win32') return unsupported(platform, 'installed');
105
+ return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
106
+ }
107
+
108
+ export async function uninstallService(deps = {}) {
109
+ const platform = deps.platform || process.platform;
110
+ if (platform !== 'win32') return unsupported(platform, 'uninstalled');
111
+ return winUninstall({
112
+ dir: deps.dir || globalDir(),
113
+ execFileImpl: deps.execFileImpl || execFileP,
114
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
115
+ });
116
+ }
117
+
118
+ export async function serviceStatus(opts = {}, deps = {}) {
119
+ const platform = deps.platform || process.platform;
120
+ if (platform !== 'win32') return { supported: false, platform };
121
+ return winStatus({
122
+ dir: deps.dir || globalDir(),
123
+ execFileImpl: deps.execFileImpl || execFileP,
124
+ probeImpl: deps.probeImpl || (async () => false),
125
+ port: opts.port || 5173,
126
+ });
127
+ }
@@ -0,0 +1,201 @@
1
+ // SessionReporter — the proactive, consented "is this user okay?" feed.
2
+ //
3
+ // WHY: the get-in path is self-serve, but if a real user gets stuck or their
4
+ // install breaks, we were blind unless they ran `operator enable` and messaged
5
+ // us — which makes us a bottleneck (docs/user-experience.md §5). This forwards a
6
+ // LIVE, REDACTED stream of session events + install health to bmo-sync, keyed by
7
+ // account, established at first load — so a stuck/broken user is never invisible
8
+ // and never has to ask.
9
+ //
10
+ // PRIVACY — the load-bearing boundary: this feed carries WHAT happened (a turn
11
+ // ran, tool X fired, an error, token/cost), NEVER the words. Chat text, tool
12
+ // inputs, file contents, and paths are reduced to lengths/counts before anything
13
+ // leaves the machine. Conversation *content* is a separate, separately-consented
14
+ // channel (transcript.mjs). `redactEvent` is an ALLOWLIST projection — any event
15
+ // type we don't explicitly model forwards only `{type}`, so a new event can
16
+ // never leak by default. The matching test asserts a secret typed into a chat
17
+ // turn never appears in the payload.
18
+ //
19
+ // Modeled on error-reporter.mjs (fire-and-forget, rate-limited, disable-able).
20
+ // Gated by BOTH consent (user toggle) AND the shared WILD_WORKSPACE_NO_TELEMETRY
21
+ // kill switch; inert without an accountToken (can't key it) or on a localhost
22
+ // bmo-sync URL (dev).
23
+
24
+ import os from 'node:os';
25
+ import { APP_VERSION } from './config.mjs';
26
+
27
+ function sanitizeUsage(u) {
28
+ if (!u || typeof u !== 'object') return null;
29
+ const out = {
30
+ input_tokens: Number(u.input_tokens) || 0,
31
+ output_tokens: Number(u.output_tokens) || 0,
32
+ };
33
+ if (typeof u.cost_usd === 'number') out.cost_usd = u.cost_usd;
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Project one ActivityBus event to a SAFE shape. Allowlist by type; anything not
39
+ * listed forwards only {type, ts, id}. NEVER returns chat text, tool inputs,
40
+ * file paths, or file contents.
41
+ */
42
+ export function redactEvent(ev) {
43
+ if (!ev || typeof ev !== 'object') return null;
44
+ const base = { type: ev.type, ts: ev.ts, id: ev.id };
45
+ if (ev.messageId) base.messageId = ev.messageId;
46
+ switch (ev.type) {
47
+ case 'chat-user':
48
+ // The user's prompt — length only, never the words.
49
+ return { ...base, textLen: typeof ev.text === 'string' ? ev.text.length : 0 };
50
+ case 'chat-stream': {
51
+ const c = ev.chunk || {};
52
+ const safe = { ...base, chunkType: c.type };
53
+ if (c.type === 'text' && typeof c.text === 'string') safe.textLen = c.text.length;
54
+ // tool NAME is safe ("Edit", "Bash"); the tool INPUT is not — never forward it.
55
+ if (c.type === 'tool-use') safe.tool = typeof c.name === 'string' ? c.name : c.tool || null;
56
+ if (c.type === 'usage' && c.usage) safe.usage = sanitizeUsage(c.usage);
57
+ if (c.type === 'error') safe.hasError = true; // the flag, not the message
58
+ return safe;
59
+ }
60
+ case 'usage':
61
+ return { ...base, usage: sanitizeUsage(ev.usage) };
62
+ case 'chat-end':
63
+ return { ...base, code: ev.code };
64
+ case 'identity-changed':
65
+ return { ...base, tone: ev.tone || null }; // tone only; drop the agent's name
66
+ case 'onboarded':
67
+ return { ...base, at: ev.at || null };
68
+ case 'agent-changed':
69
+ return { ...base, agentId: ev.agentId || null };
70
+ case 'daemon-status':
71
+ return { ...base, status: ev.status || null };
72
+ case 'operator-action':
73
+ return { ...base, action: ev.action || null }; // action verb, not its detail
74
+ case 'inbox-change':
75
+ return { ...base, count: Array.isArray(ev.snapshot) ? ev.snapshot.length : undefined };
76
+ case 'presence-join':
77
+ case 'presence-leave':
78
+ case 'presence-focus':
79
+ // sessionId + role are safe; `focus` can be a file path → dropped.
80
+ return { ...base, sessionId: ev.sessionId, role: ev.role };
81
+ default:
82
+ // Unknown event → minimal envelope only. Privacy fails CLOSED.
83
+ return { type: ev.type, ts: ev.ts, id: ev.id };
84
+ }
85
+ }
86
+
87
+ const FLUSH_INTERVAL_MS = 15_000; // batch window
88
+ const MAX_BATCH = 50; // flush early once this many buffer
89
+ const MAX_BUFFER = 500; // hard cap — drop oldest beyond this
90
+
91
+ export class SessionReporter {
92
+ constructor({
93
+ bmoSyncUrl,
94
+ accountToken,
95
+ slug = null,
96
+ workspaceId = 'workspace',
97
+ sessionId = null,
98
+ enabled = true,
99
+ endpointPath = '/api/telemetry',
100
+ flushIntervalMs = FLUSH_INTERVAL_MS,
101
+ maxBatch = MAX_BATCH,
102
+ fetchImpl = (...a) => globalThis.fetch(...a),
103
+ nowImpl = () => Date.now(),
104
+ } = {}) {
105
+ this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
106
+ this.accountToken = accountToken || null;
107
+ this.slug = slug;
108
+ this.workspaceId = workspaceId;
109
+ this.sessionId = sessionId;
110
+ this.endpointPath = endpointPath;
111
+ this.flushIntervalMs = flushIntervalMs;
112
+ this.maxBatch = maxBatch;
113
+ this.fetchImpl = fetchImpl;
114
+ this.nowImpl = nowImpl;
115
+ this.buffer = [];
116
+ this.timer = null;
117
+ // Inert without a token, without a server, or on localhost (dev).
118
+ this._capable =
119
+ Boolean(this.accountToken) &&
120
+ Boolean(this.bmoSyncUrl) &&
121
+ !this.bmoSyncUrl.startsWith('http://127') &&
122
+ !this.bmoSyncUrl.startsWith('http://localhost');
123
+ this.enabled = enabled !== false && this._capable;
124
+ }
125
+
126
+ /** Live consent toggle — no restart needed when the user flips observability. */
127
+ setEnabled(on) {
128
+ this.enabled = Boolean(on) && this._capable;
129
+ if (!this.enabled) this.buffer = [];
130
+ }
131
+
132
+ /** Feed one ActivityBus event. Redacts + buffers; flushes on size. No-op when off. */
133
+ ingest(ev) {
134
+ if (!this.enabled) return;
135
+ const safe = redactEvent(ev);
136
+ if (!safe) return;
137
+ this.buffer.push(safe);
138
+ if (this.buffer.length > MAX_BUFFER) this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
139
+ if (this.buffer.length >= this.maxBatch) this.flush();
140
+ }
141
+
142
+ /** POST the install-health snapshot alongside events (called by the supervisor path too). */
143
+ envelope(events, extra = {}) {
144
+ return {
145
+ account_token: this.accountToken,
146
+ slug: this.slug,
147
+ workspace_id: this.workspaceId,
148
+ session_id: this.sessionId,
149
+ app_version: APP_VERSION,
150
+ os: `${os.platform()}-${os.arch()}`,
151
+ sent_at: Math.floor(this.nowImpl() / 1000),
152
+ events,
153
+ ...extra,
154
+ };
155
+ }
156
+
157
+ /** Fire-and-forget flush of the current buffer. Never throws. */
158
+ flush() {
159
+ if (!this.enabled || !this.buffer.length) return;
160
+ const events = this.buffer;
161
+ this.buffer = [];
162
+ this._post(this.envelope(events));
163
+ }
164
+
165
+ _post(body) {
166
+ const url = `${this.bmoSyncUrl}${this.endpointPath}`;
167
+ const ctrl = new AbortController();
168
+ const timer = setTimeout(() => ctrl.abort(), 5000);
169
+ if (timer.unref) timer.unref();
170
+ let p;
171
+ try {
172
+ // Call synchronously so the request is observable without awaiting a tick.
173
+ p = this.fetchImpl(url, {
174
+ method: 'POST',
175
+ headers: { 'content-type': 'application/json' },
176
+ body: JSON.stringify(body),
177
+ signal: ctrl.signal,
178
+ });
179
+ } catch {
180
+ clearTimeout(timer);
181
+ return; // telemetry must never break the user's path
182
+ }
183
+ Promise.resolve(p)
184
+ .catch(() => {})
185
+ .finally(() => clearTimeout(timer));
186
+ }
187
+
188
+ start() {
189
+ if (this.timer || !this._capable) return;
190
+ this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
191
+ if (this.timer.unref) this.timer.unref(); // never keep the process alive
192
+ }
193
+
194
+ stop() {
195
+ if (this.timer) {
196
+ clearInterval(this.timer);
197
+ this.timer = null;
198
+ }
199
+ this.flush();
200
+ }
201
+ }