wisp-agents 0.12.0 → 0.14.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.
@@ -5,6 +5,10 @@
5
5
  //
6
6
  // Off when any of: no API key, DO_NOT_TRACK=1, WISP_TELEMETRY=0, CI, or "telemetry": "off" in
7
7
  // settings. See the Telemetry section of the README.
8
+ //
9
+ // Feedback (thumbs on a result, /feedback, "Send feedback") is different: the user hands it to us
10
+ // on purpose, so it goes out even with usage stats off. Only WISP_FEEDBACK=0 or no key stops it.
11
+ // Offline, it waits in feedback-outbox.json and goes with the next feedback or the next start.
8
12
 
9
13
  import fs from 'node:fs';
10
14
  import os from 'node:os';
@@ -22,10 +26,16 @@ const TIMEOUT_MS = 3000;
22
26
  const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
27
  const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
24
28
 
29
+ const falsy = (v) => /^(0|false|no|off)$/i.test(v || '');
30
+
25
31
  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);
32
+ return !KEY || truthy(env.DO_NOT_TRACK) || falsy(env.WISP_TELEMETRY) || truthy(env.CI);
27
33
  }
28
34
 
35
+ export const feedbackAvailable = (env = process.env) => !!KEY && !falsy(env.WISP_FEEDBACK);
36
+ const FEEDBACK_MAX = 4000;
37
+ const OUTBOX_MAX = 100;
38
+
29
39
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
30
40
 
31
41
  // Paths in messages and stacks would leak usernames and project names: wisp's own files keep
@@ -50,10 +60,12 @@ function frames(stack = '') {
50
60
  }
51
61
 
52
62
  export class Telemetry {
53
- constructor(store, { shell = process.versions.electron ? 'electron' : 'browser' } = {}) {
63
+ constructor(store, { shell = process.versions.electron ? 'electron' : 'browser', post } = {}) {
54
64
  this.store = store;
55
65
  this.queue = [];
56
66
  this.pendingFile = path.join(store.home, 'telemetry-pending.json');
67
+ this.outboxFile = path.join(store.home, 'feedback-outbox.json');
68
+ if (post) this.post = post;
57
69
  this.common = {
58
70
  $lib: 'wisp',
59
71
  $process_person_profile: false, // anonymous events: no person profiles
@@ -68,6 +80,7 @@ export class Telemetry {
68
80
  this.timer = setInterval(() => this.flush(), FLUSH_MS);
69
81
  this.timer.unref();
70
82
  this.#sendPending();
83
+ this.#sendOutbox();
71
84
  }
72
85
 
73
86
  get enabled() {
@@ -93,17 +106,49 @@ export class Telemetry {
93
106
  });
94
107
  }
95
108
 
96
- async flush() {
97
- if (!this.queue.length) return;
98
- const batch = this.queue.splice(0);
109
+ // Thumbs on a turn ('turn_rated') or a written note ('feedback'). Resolves true once PostHog has
110
+ // it; false means it's kept in the outbox and retried.
111
+ async feedback(event, props = {}) {
112
+ if (!feedbackAvailable()) return false;
113
+ // With usage stats off, a fresh id per message so feedback can't be tied to anything else.
114
+ const id = (this.enabled && this.store.config.telemetryId) || randomUUID();
115
+ const text = props.text ? String(props.text).split(os.homedir()).join('~').slice(0, FEEDBACK_MAX) : undefined;
116
+ const ev = { event, timestamp: new Date().toISOString(), properties: { ...this.common, ...props, text, distinct_id: id } };
117
+ const batch = [...this.#outbox(), ev].slice(-OUTBOX_MAX);
118
+ const ok = await this.post(batch);
119
+ try {
120
+ if (ok) fs.rmSync(this.outboxFile, { force: true });
121
+ else fs.writeFileSync(this.outboxFile, JSON.stringify(batch));
122
+ } catch {}
123
+ return ok;
124
+ }
125
+
126
+ #outbox() {
127
+ try {
128
+ const list = JSON.parse(fs.readFileSync(this.outboxFile, 'utf8'));
129
+ return Array.isArray(list) ? list : [];
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ async post(batch) {
99
136
  try {
100
- await fetch(`${HOST}/batch/`, {
137
+ const res = await fetch(`${HOST}/batch/`, {
101
138
  method: 'POST',
102
139
  headers: { 'content-type': 'application/json' },
103
140
  body: JSON.stringify({ api_key: KEY, batch }),
104
141
  signal: AbortSignal.timeout(TIMEOUT_MS),
105
142
  });
106
- } catch {} // offline or blocked: drop it, telemetry must never get in the way
143
+ return res.ok;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ async flush() {
150
+ if (!this.queue.length) return;
151
+ await this.post(this.queue.splice(0)); // offline or blocked: drop it, telemetry must never get in the way
107
152
  }
108
153
 
109
154
  // Shutdown and crashes leave no time for a request: write what's queued to disk (sync) and
@@ -122,6 +167,12 @@ export class Telemetry {
122
167
  this.persist();
123
168
  }
124
169
 
170
+ #sendOutbox() {
171
+ const batch = this.#outbox();
172
+ if (!batch.length || !feedbackAvailable()) return;
173
+ this.post(batch).then((ok) => ok && fs.rmSync(this.outboxFile, { force: true }));
174
+ }
175
+
125
176
  #sendPending() {
126
177
  try {
127
178
  const pending = JSON.parse(fs.readFileSync(this.pendingFile, 'utf8'));
package/web/css/app.css CHANGED
@@ -305,7 +305,8 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
305
305
  .l-bar { display: flex; align-items: center; gap: 8px; padding-top: 6px; }
306
306
  .l-bar > :not(.runners) { flex: none; }
307
307
  .l-bar .grow { flex: 1; }
308
- /* harness pill(s): "vs" rides on the end of it; on, the second harness joins the same pill */
308
+ /* harness pill(s): "vs" rides on the end of it; each harness added joins the same pill behind a
309
+ "vs" of its own (hover: ×, removes it), and the end button turns into a + for the next one */
309
310
  .runners {
310
311
  display: inline-flex; align-items: stretch; min-width: 0; height: 30px; border-radius: 9px;
311
312
  border: 1px solid var(--line); background: var(--surface); transition: border-color 0.15s;
@@ -318,6 +319,12 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
318
319
  .runners .rb-h { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
319
320
  .runners .rb-m { flex: 0 100 auto; min-width: 0; }
320
321
  .runners.cmp .rb-e, .l-bar:has(.runners.cmp) #wtBtn span { display: none; }
322
+ /* three or four harnesses: logos only (each button's tooltip names it), the bar's other labels fold too */
323
+ .runners.many .runner-btn { flex: none; padding-inline: 6px; }
324
+ .runners.many :is(.rb-h, .rb-m) { display: none; }
325
+ .runners.many .rb-logo { margin-right: 3px; }
326
+ .runners.many .runner-btn .chev { margin-left: 0; }
327
+ .l-bar:has(.runners.many) :is(.mode-btn span, .team-btn .tb-label) { display: none; }
321
328
  /* two harnesses on a narrow bar: drop the model names, then the harness names (logos stay) */
322
329
  @container launcher (max-width: 640px) { .runners.cmp .rb-m { display: none; } }
323
330
  @container launcher (max-width: 520px) {
@@ -326,20 +333,29 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
326
333
  .runners.cmp .runner-btn .chev { margin-left: 0; }
327
334
  .runners.cmp .rb-logo { margin-right: 4px; }
328
335
  }
336
+ @container launcher (max-width: 600px) {
337
+ .runners.many .runner-btn .chev { display: none; }
338
+ .runners.many .vs-del { width: 18px; }
339
+ }
329
340
  .vs-btn {
330
341
  position: relative; flex: none; display: grid; place-items: center; width: 30px;
331
342
  font: 600 11px var(--mono); letter-spacing: 0.02em; color: var(--faint); transition: color 0.15s, background 0.15s;
332
343
  }
333
- .vs-btn::before { content: ''; position: absolute; left: 0; top: 7px; bottom: 7px; width: 1px; background: var(--line); }
344
+ .vs-btn[hidden] { display: none; }
334
345
  .vs-btn:hover { color: var(--accent-ink); }
335
346
  .vs-btn .i { display: none; width: 13px; height: 13px; }
336
347
  .vs-btn:focus-visible { outline: 0; border-radius: 6px; box-shadow: inset 0 0 0 1px var(--accent); }
348
+ .vs-add::before { content: ''; position: absolute; left: 0; top: 7px; bottom: 7px; width: 1px; background: var(--line); }
337
349
  .runners.cmp { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
338
- .runners.cmp .vs-btn { width: 26px; color: var(--accent-ink); }
339
- .runners.cmp .vs-btn::before { display: none; }
340
- .runners.cmp .vs-btn:hover { color: var(--ink); }
341
- .runners.cmp .vs-btn:is(:hover, :focus-visible) span { display: none; }
342
- .runners.cmp .vs-btn:is(:hover, :focus-visible) .i { display: block; }
350
+ .runners.cmp .vs-add { width: 26px; }
351
+ .runners.cmp .vs-add span { display: none; }
352
+ .runners.cmp .vs-add .i { display: block; width: 12px; height: 12px; }
353
+ .vs-del { width: 24px; color: var(--accent-ink); }
354
+ .runners.many .vs-del { width: 20px; }
355
+ .vs-del:hover { color: var(--bad); }
356
+ .vs-del:not(.fresh):is(:hover, :focus-visible) span { display: none; }
357
+ .vs-del:not(.fresh):is(:hover, :focus-visible) .i { display: block; }
358
+ .vs-del.fresh { cursor: default; }
343
359
  /* permission mode: icon-only on the default, labelled otherwise */
344
360
  .mode-btn {
345
361
  display: inline-flex; align-items: center; gap: 6px; height: 32px; min-width: 32px; padding: 0 9px; border-radius: 9px;
@@ -1070,6 +1086,28 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1070
1086
  .trail-bar .link { font-size: 12px; }
1071
1087
  .trail-body { padding: 8px 0 0; animation: rise 0.4s var(--ease); }
1072
1088
  .trail-body .presentation { gap: 10px; }
1089
+ /* Thumbs: faint until the trail is hovered or the turn is rated, so a finished turn reads the same. */
1090
+ .rate { display: inline-flex; gap: 1px; margin-left: -6px; opacity: 0.5; transition: opacity 0.15s; }
1091
+ .turn:hover .rate, .rate.rated, .rate:focus-within { opacity: 1; }
1092
+ .rate-btn { display: inline-grid; place-items: center; width: 24px; height: 22px; border-radius: 6px; color: var(--faint); transition: color 0.15s, background 0.15s; }
1093
+ .rate-btn .i { width: 13px; height: 13px; }
1094
+ .rate-btn:hover { color: var(--ink); background: var(--bg-2); }
1095
+ .rate-btn[data-rate="up"].on { color: var(--good); }
1096
+ .rate-btn[data-rate="down"].on { color: var(--bad); }
1097
+ .rate-btn.on .i { fill: color-mix(in srgb, currentColor 18%, transparent); }
1098
+ .rate.thanked [data-rate="up"].on .i { animation: thumb-pop 0.45s var(--ease); }
1099
+ @keyframes thumb-pop { 40% { transform: scale(1.35) rotate(-12deg); } }
1100
+ .why { display: grid; gap: 6px; }
1101
+ .why .textarea { min-height: 0; resize: none; }
1102
+ .why-foot { display: flex; align-items: center; gap: 10px; }
1103
+ .why-foot .muted { margin-right: auto; }
1104
+ .why-foot .primary { height: 24px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
1105
+ .fb-modal { width: min(520px, 100%); }
1106
+ .fb-rate { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; font-size: 13px; color: var(--muted); }
1107
+ .fb-rate span { margin-right: 6px; }
1108
+ .fb-rate .rate-btn { width: 30px; height: 28px; }
1109
+ .fb-rate .rate-btn .i { width: 16px; height: 16px; }
1110
+ .fb-modal .ship-foot { margin-top: 12px; }
1073
1111
 
1074
1112
  /* ---------- per-item composer ---------- */
1075
1113
  .composer {
@@ -1087,6 +1125,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1087
1125
  .cq .att-x:hover { color: var(--bad); }
1088
1126
  .cq-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
1089
1127
  .c-hint { flex: 1; font-size: 11.5px; color: var(--faint); }
1128
+ .c-bar .mode-btn { height: 30px; min-width: 30px; padding: 0 8px; }
1129
+ .c-bar .mode-btn.changed { background: var(--bg-2); }
1130
+ .t-mode { display: inline-flex; align-items: center; gap: 4px; padding: 1px 6px; border-radius: 5px; font: 500 11px var(--sans); color: var(--muted); border: 1px solid var(--line); }
1131
+ .t-mode .i { width: 12px; height: 12px; }
1132
+ .t-mode.m-plan { color: var(--accent-ink); }
1133
+ .t-mode.m-auto { color: var(--warn); }
1090
1134
  .send { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--ink); color: var(--bg); transition: opacity 0.15s; }
1091
1135
  .send:hover { opacity: 0.86; }
1092
1136
  .send:disabled { opacity: 0.5; }
@@ -1693,6 +1737,8 @@ body.electron.mac .topbar { padding-left: 88px; }
1693
1737
  .ph-ok { color: var(--good); }
1694
1738
  .ph-bad { color: var(--bad); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1695
1739
  .ph-ok .i, .ph-bad .i { width: 12px; height: 12px; }
1740
+ .ph-start { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
1741
+ .ph-row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 6px; }
1696
1742
 
1697
1743
  /* ---------- subagents ---------- */
1698
1744
  /* logo stack: the lead in front, its subagents tucked behind; they fan out on hover */
@@ -1823,3 +1869,33 @@ body.electron.mac .topbar { padding-left: 88px; }
1823
1869
  .sub-task p { margin: 3px 0 0; font-size: 13px; color: var(--ink-2); white-space: pre-wrap; }
1824
1870
  .sub-wait { margin: 0; font-size: 13px; }
1825
1871
  @container (max-width: 520px) { .sub-files, .sub-dur, .sub-model { display: none; } }
1872
+
1873
+ /* Auto: wisp's router as a harness (picker, card badge, Settings → Auto) */
1874
+ .auto-mark { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
1875
+ .auto-mark .i { width: 100%; height: 100%; }
1876
+ .model-logo.routed::before { content: ''; position: absolute; right: -3px; bottom: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 1.5px var(--surface); }
1877
+ .pk-note { margin: 8px 10px 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
1878
+ .pk-note a { color: var(--accent-ink); }
1879
+ .ro-goal { flex: none; }
1880
+ .ro-allowed { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; max-width: 360px; }
1881
+ .chip-tog { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
1882
+ .chip-tog:hover { color: var(--ink); }
1883
+ .chip-tog.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
1884
+ .router-modal { width: min(720px, 100%); position: relative; padding-bottom: 20px; }
1885
+ .router-modal .hint { margin: 0 0 12px; }
1886
+ .router-modal h4 { margin: 16px 0 6px; }
1887
+ .ro-table td small.ro-types { display: block; margin: 2px 0 0 19px; font-size: 11px; color: var(--faint); }
1888
+ .ro-table .sx-state { justify-content: flex-end; }
1889
+ .sx-state.ok { color: var(--good); }
1890
+ .sx-state.warn { color: var(--warn); }
1891
+ .dot.warn { background: var(--warn); }
1892
+ .ro-recent { list-style: none; margin: 0; padding: 0; }
1893
+ .ro-recent li { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
1894
+ .ro-recent .ro-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
1895
+ .ro-recent small { flex: none; color: var(--faint); }
1896
+ .ro-recent .ok { color: var(--good); }
1897
+ .ro-recent .warn { color: var(--warn); }
1898
+ .ro-recent .bad { color: var(--bad); }
1899
+ .ro-recent .dim { color: var(--faint); }
1900
+ .ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
1901
+ .ro-foot .hint { margin: 0; }
package/web/index.html CHANGED
@@ -56,6 +56,8 @@
56
56
  <symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 7.5V12l3 2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
57
57
  <symbol id="i-laptop" viewBox="0 0 24 24"><rect x="4.5" y="5" width="15" height="10.5" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M2.5 19h19" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
58
58
  <symbol id="i-server" viewBox="0 0 24 24"><rect x="3.5" y="4" width="17" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><rect x="3.5" y="13" width="17" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="7.3" cy="7.5" r="1.1" fill="currentColor"/><circle cx="7.3" cy="16.5" r="1.1" fill="currentColor"/><path d="M11 7.5h5.5M11 16.5h5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
59
+ <symbol id="i-thumb-up" viewBox="0 0 24 24"><path d="M7.5 10.5v9H4.8a.8.8 0 01-.8-.8v-7.4a.8.8 0 01.8-.8h2.7zm0 0l3.6-6.3a1.7 1.7 0 013.1 1.2l-.6 3.6h4.6a2 2 0 012 2.3l-1.1 6.4a2.4 2.4 0 01-2.4 2.1H7.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
60
+ <symbol id="i-thumb-down" viewBox="0 0 24 24"><path d="M7.5 13.5v-9H4.8a.8.8 0 00-.8.8v7.4a.8.8 0 00.8.8h2.7zm0 0l3.6 6.3a1.7 1.7 0 003.1-1.2l-.6-3.6h4.6a2 2 0 002-2.3l-1.1-6.4a2.4 2.4 0 00-2.4-2.1H7.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
59
61
  <symbol id="i-play" viewBox="0 0 24 24"><path d="M7 5.5v13l11-6.5-11-6.5z" fill="currentColor"/></symbol>
60
62
  </defs>
61
63
  </svg>