wisp-agents 0.4.0 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.0] - 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - Anonymous usage stats and error reports via [PostHog](https://posthog.com): which agents, models and modes are used, run outcomes and timings, and errors (agent failures, server errors, crashes) with paths scrubbed. Never sends prompts, output, paths or project names. Turn it off in Settings → *Anonymous usage stats*, or with `WISP_TELEMETRY=0` / `DO_NOT_TRACK=1`; always off when `CI` is set. See the Telemetry section of the README.
13
+
8
14
  ## [0.4.0] - 2026-10-17
9
15
 
10
16
  ### Added
@@ -83,7 +89,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
83
89
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
84
90
  - Older turns are folded, with a floating collapse-all button and back-to-top.
85
91
 
86
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.4.0...HEAD
92
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.5.0...HEAD
93
+ [0.5.0]: https://github.com/M0xoo/wisp/compare/v0.4.0...v0.5.0
87
94
  [0.4.0]: https://github.com/M0xoo/wisp/compare/v0.3.0...v0.4.0
88
95
  [0.3.0]: https://github.com/M0xoo/wisp/compare/v0.2.2...v0.3.0
89
96
  [0.2.2]: https://github.com/M0xoo/wisp/compare/v0.2.1...v0.2.2
package/README.md CHANGED
@@ -202,6 +202,21 @@ If the harness has no system-prompt flag, prepend `systemPrompt` to the first pr
202
202
  - [x] opencode adapter
203
203
  - [x] Cursor CLI adapter
204
204
 
205
+ ## Telemetry
206
+
207
+ wisp sends anonymous usage stats and error reports to [PostHog](https://posthog.com) so we can see which agents people use and what breaks. The code is in [`server/telemetry.js`](server/telemetry.js).
208
+
209
+ **What is sent:** a random install id (not tied to you or your machine), the wisp, OS, Node and agent CLI versions, and per run: agent, model, mode, effort, status, duration, token counts and cost, how many files changed, and which presentation block types came back. Errors include a stack trace and message with your home directory and file paths removed, cut to 500 characters.
210
+
211
+ **Never sent:** prompts, replies, agent output, file contents, project names, paths or repo URLs.
212
+
213
+ **Opt out** with any of these:
214
+ - Settings → *Anonymous usage stats*
215
+ - `WISP_TELEMETRY=0`
216
+ - `DO_NOT_TRACK=1`
217
+
218
+ It is also off whenever `CI` is set.
219
+
205
220
  ## Contributing
206
221
 
207
222
  Issues and PRs are welcome. wisp has no build step: edit `web/` or `server/`, then run `npm run dev` (or `npm run dev:web`) to see your changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.js CHANGED
@@ -11,6 +11,7 @@ import { Runner, summarize, newId } from './runner.js';
11
11
  import { adapters, listAgents } from './adapters/index.js';
12
12
  import { welcomeSession } from './welcome.js';
13
13
  import { openInTerminal, TERMINALS, shq } from './terminal.js';
14
+ import { Telemetry, envDisabled } from './telemetry.js';
14
15
 
15
16
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
16
17
 
@@ -62,6 +63,29 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
62
63
  runner.on('finished', (f) => broadcast('finished', f));
63
64
  setInterval(() => broadcast('ping', {}), 25_000).unref();
64
65
 
66
+ const telemetry = new Telemetry(store);
67
+ const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
68
+ runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
69
+ runner.on('ended', ({ session, turn, interrupted, errorText }) => {
70
+ const blocks = turn.presentation?.blocks || [];
71
+ const st = turn.stats || {};
72
+ telemetry.track('turn_ended', {
73
+ ...run(session, turn),
74
+ status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
75
+ heroStatus: blocks.find((b) => b.type === 'hero')?.status,
76
+ durationMs: turn.endedAt - turn.startedAt,
77
+ blockCount: blocks.length,
78
+ blockTypes: [...new Set(blocks.map((b) => b.type))],
79
+ presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
80
+ changedFiles: turn.changes.length,
81
+ inputTokens: st.inputTokens,
82
+ outputTokens: st.outputTokens,
83
+ costUsd: st.costUsd,
84
+ });
85
+ if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...run(session, turn) });
86
+ });
87
+ if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
88
+
65
89
  const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
66
90
 
67
91
  const server = http.createServer(async (req, res) => {
@@ -81,6 +105,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
81
105
  return serveStatic(res, url.pathname);
82
106
  } catch (err) {
83
107
  console.error(err);
108
+ if (!err.status || err.status >= 500) telemetry.captureError(err, { route: `${req.method} ${url.pathname.replace(/\/[a-f0-9]{8,}(?=\/|$)/g, '/:id')}` });
84
109
  send(res, err.status || 500, { error: err.message });
85
110
  }
86
111
  });
@@ -107,6 +132,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
107
132
  const body = await readJson(req);
108
133
  const s = store.get(body.session);
109
134
  if (!s) return send(res, 404, { error: 'no such session' });
135
+ telemetry.track('ask_user', { agent: s.agent });
110
136
  return send(res, 200, { id: runner.ask(s, body) });
111
137
  }
112
138
  if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
@@ -148,7 +174,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
148
174
  }
149
175
 
150
176
  if (root === 'config') {
151
- if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS });
177
+ if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
152
178
  if (m === 'PATCH') {
153
179
  const body = await readJson(req);
154
180
  if (body.defaults) Object.assign(store.config.defaults, body.defaults);
@@ -297,6 +323,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
297
323
  }
298
324
  openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
299
325
  s.takenOverAt = Date.now();
326
+ telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
300
327
  store.save(s);
301
328
  return send(res, 200, { command });
302
329
  }
@@ -341,6 +368,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
341
368
  const shutdown = () => {
342
369
  for (const s of store.all()) runner.stop(s);
343
370
  store.flush();
371
+ telemetry.persist();
344
372
  };
345
373
  if (handleSignals) {
346
374
  const exit = () => (shutdown(), process.exit(0));
@@ -353,7 +381,14 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
353
381
  server.listen(port, host, resolve);
354
382
  });
355
383
 
356
- return { url: `http://localhost:${port}`, server, store, runner, shutdown };
384
+ if (telemetry.enabled) {
385
+ if (store.config.defaults.telemetryNotice !== 'seen') console.log('wisp sends anonymous usage stats and error reports (no prompts, paths or code). Opt out: Settings, or WISP_TELEMETRY=0 / DO_NOT_TRACK=1.');
386
+ listAgents()
387
+ .then((agents) => telemetry.track('app_started', { agentsInstalled: agents.filter((a) => a.available).map((a) => a.id), ...Object.fromEntries(agents.filter((a) => a.available).map((a) => [`${a.id}Version`, a.version])) }))
388
+ .catch(() => {});
389
+ }
390
+
391
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
357
392
  }
358
393
 
359
394
  function send(res, status, body) {
package/server/runner.js CHANGED
@@ -220,6 +220,7 @@ export class Runner extends EventEmitter {
220
220
  session.unread = false;
221
221
  delete session.archived;
222
222
  this.#touch(session, { turn: turn.id });
223
+ this.emit('started', { session, turn });
223
224
 
224
225
  let released = false;
225
226
  let linger;
@@ -341,6 +342,7 @@ export class Runner extends EventEmitter {
341
342
  session.unread = !interrupted;
342
343
  this.store.save(session, { now: true });
343
344
  this.#touch(session, { turn: turn.id });
345
+ this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
344
346
  // The follow-up starts right away; don't notify for the run it replaced.
345
347
  if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
346
348
  }
@@ -0,0 +1,132 @@
1
+ // Anonymous usage analytics + error reports, sent to PostHog over its plain HTTP API (no SDK, to
2
+ // stay dependency-free). What goes out is fixed values only — agent, model, mode, status, timings,
3
+ // counts — never prompts, paths, project names or agent output. Error messages and stacks are
4
+ // scrubbed of the home directory and truncated.
5
+ //
6
+ // Off when any of: no API key, DO_NOT_TRACK=1, WISP_TELEMETRY=0, CI, or "telemetry": "off" in
7
+ // settings. See the Telemetry section of the README.
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ // PostHog project API key: write-only, safe to ship in the client.
16
+ const KEY = process.env.WISP_POSTHOG_KEY ?? 'phc_wowwZkbP8e4zYuwT67JtX6oxdPwkMWL5kKnrUmYYXhcD';
17
+ const HOST = (process.env.WISP_POSTHOG_HOST || 'https://us.i.posthog.com').replace(/\/$/, '');
18
+ const FLUSH_MS = 15_000;
19
+ const MAX_QUEUE = 50;
20
+ const TIMEOUT_MS = 3000;
21
+
22
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
+ const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
24
+
25
+ export function envDisabled(env = process.env) {
26
+ return !KEY || truthy(env.DO_NOT_TRACK) || /^(0|false|no|off)$/i.test(env.WISP_TELEMETRY || '') || truthy(env.CI);
27
+ }
28
+
29
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
30
+
31
+ // Paths in messages and stacks would leak usernames and project names: wisp's own files keep
32
+ // their package-relative path, anything else is cut down to its file name.
33
+ export function scrub(text = '', { home = os.homedir(), root = ROOT } = {}) {
34
+ let s = String(text).split(root).join('<wisp>');
35
+ if (home) s = s.split(home).join('~');
36
+ // Any other absolute path keeps only its file name.
37
+ return s
38
+ .replace(/(?<![\w<>~.:/\\-])(?:~|[A-Za-z]:)?[/\\](?:[^\s'"`():/\\]+[/\\])+([^\s'"`():/\\]*)/g, (m, base) => `…/${base}`)
39
+ .slice(0, 500);
40
+ }
41
+
42
+ function frames(stack = '') {
43
+ return String(stack)
44
+ .split('\n')
45
+ .slice(1, 30)
46
+ .map((l) => l.trim().match(/^at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/))
47
+ .filter(Boolean)
48
+ .map(([, fn, file, line, col]) => ({ platform: 'custom', lang: 'javascript', function: fn || '?', filename: scrub(file.replace(/^file:\/\//, '')), lineno: +line, colno: +col, in_app: file.includes(ROOT) && !file.includes('node_modules') }))
49
+ .reverse(); // PostHog wants the innermost frame last
50
+ }
51
+
52
+ export class Telemetry {
53
+ constructor(store, { shell = process.versions.electron ? 'electron' : 'browser' } = {}) {
54
+ this.store = store;
55
+ this.queue = [];
56
+ this.pendingFile = path.join(store.home, 'telemetry-pending.json');
57
+ this.common = {
58
+ $lib: 'wisp',
59
+ $process_person_profile: false, // anonymous events: no person profiles
60
+ version: VERSION,
61
+ os: process.platform,
62
+ osRelease: os.release(),
63
+ arch: process.arch,
64
+ node: process.versions.node,
65
+ electron: process.versions.electron || undefined,
66
+ shell,
67
+ };
68
+ this.timer = setInterval(() => this.flush(), FLUSH_MS);
69
+ this.timer.unref();
70
+ this.#sendPending();
71
+ }
72
+
73
+ get enabled() {
74
+ return !envDisabled() && this.store.config.defaults.telemetry !== 'off';
75
+ }
76
+
77
+ track(event, props = {}) {
78
+ if (!this.enabled) return;
79
+ if (!this.store.config.telemetryId) {
80
+ this.store.config.telemetryId = randomUUID(); // random per install, not tied to the machine or user
81
+ this.store.saveConfig();
82
+ }
83
+ this.queue.push({ event, timestamp: new Date().toISOString(), properties: { ...this.common, ...props, distinct_id: this.store.config.telemetryId } });
84
+ if (this.queue.length >= MAX_QUEUE) this.flush();
85
+ }
86
+
87
+ // PostHog error tracking format.
88
+ captureError(err, props = {}) {
89
+ const e = err instanceof Error ? err : new Error(String(err));
90
+ this.track('$exception', {
91
+ ...props,
92
+ $exception_list: [{ type: props.type || e.name || 'Error', value: scrub(e.message), mechanism: { handled: props.handled ?? true, synthetic: false }, stacktrace: { type: 'raw', frames: frames(e.stack) } }],
93
+ });
94
+ }
95
+
96
+ async flush() {
97
+ if (!this.queue.length) return;
98
+ const batch = this.queue.splice(0);
99
+ try {
100
+ await fetch(`${HOST}/batch/`, {
101
+ method: 'POST',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ api_key: KEY, batch }),
104
+ signal: AbortSignal.timeout(TIMEOUT_MS),
105
+ });
106
+ } catch {} // offline or blocked: drop it, telemetry must never get in the way
107
+ }
108
+
109
+ // Shutdown and crashes leave no time for a request: write what's queued to disk (sync) and
110
+ // send it on the next start.
111
+ persist() {
112
+ clearInterval(this.timer);
113
+ if (!this.queue.length) return;
114
+ try {
115
+ fs.writeFileSync(this.pendingFile, JSON.stringify(this.queue.splice(0)));
116
+ } catch {}
117
+ }
118
+
119
+ crashed(err) {
120
+ if (!this.enabled) return;
121
+ this.captureError(err, { handled: false, type: err?.name || 'UncaughtException' });
122
+ this.persist();
123
+ }
124
+
125
+ #sendPending() {
126
+ try {
127
+ const pending = JSON.parse(fs.readFileSync(this.pendingFile, 'utf8'));
128
+ fs.rmSync(this.pendingFile, { force: true });
129
+ if (this.enabled && Array.isArray(pending)) this.queue.push(...pending.slice(-200));
130
+ } catch {}
131
+ }
132
+ }
package/web/js/app.js CHANGED
@@ -1701,6 +1701,7 @@ function openSettings() {
1701
1701
  <label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
1702
1702
  <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1703
1703
  <div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
1704
+ <div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
1704
1705
  <div class="agents">${S.agents
1705
1706
  .map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
1706
1707
  .join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
@@ -1733,6 +1734,28 @@ function openSettings() {
1733
1734
  paint();
1734
1735
  };
1735
1736
  paint();
1737
+ // Agent, model, status, timings and scrubbed errors only; never prompts, paths or code.
1738
+ const tele = $('#tele', node);
1739
+ const paintTele = () => {
1740
+ const t = S.config.telemetry || {};
1741
+ tele.setAttribute('aria-checked', !!t.enabled);
1742
+ tele.disabled = !t.available;
1743
+ $('.tele-hint', node).textContent = !t.available ? 'Off (DO_NOT_TRACK, WISP_TELEMETRY=0 or CI)' : 'No prompts, paths or code';
1744
+ };
1745
+ tele.onclick = async () => {
1746
+ await api('/config', { method: 'PATCH', body: { defaults: { telemetry: S.config.telemetry?.enabled ? 'off' : 'on' } } });
1747
+ await loadConfig();
1748
+ paintTele();
1749
+ };
1750
+ paintTele();
1751
+ }
1752
+
1753
+ // Say once that anonymous stats are on, with a way out.
1754
+ function telemetryNotice() {
1755
+ if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
1756
+ S.config.defaults.telemetryNotice = 'seen';
1757
+ api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
1758
+ toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: openSettings } });
1736
1759
  }
1737
1760
 
1738
1761
  function shortcuts() {
@@ -2041,4 +2064,5 @@ window.addEventListener('focus', () => {
2041
2064
  connect();
2042
2065
  route();
2043
2066
  $('#prompt')?.focus();
2067
+ telemetryNotice();
2044
2068
  })();