wisp-agents 0.11.0 → 0.13.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/server/runner.js CHANGED
@@ -13,6 +13,7 @@ import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js
13
13
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
14
14
 
15
15
  const MAX_ACTIVITY = 400;
16
+ const MODES = new Set(['plan', 'edits', 'auto']);
16
17
  const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
17
18
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
18
19
  // The delegate tool lives on the same injected server, so harnesses name it like ask_user.
@@ -70,7 +71,7 @@ export function summarize(s, store) {
70
71
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
71
72
  pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
72
73
  groupId: s.groupId || null,
73
- origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
74
+ origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
74
75
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
75
76
  team: (s.subagents || []).flatMap((m) =>
76
77
  copies(m).map((name) => {
@@ -138,8 +139,11 @@ export class Runner extends EventEmitter {
138
139
  return session;
139
140
  }
140
141
 
141
- reply(session, text, attachments = [], { queue = false } = {}) {
142
- if (session.ask) {
142
+ // mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
143
+ // follow-up keeps its own, so the run before it isn't affected.
144
+ reply(session, text, attachments = [], { queue = false, mode } = {}) {
145
+ if (!MODES.has(mode) || mode === session.mode) mode = undefined;
146
+ if (session.ask && !mode) {
143
147
  this.answer(session, text);
144
148
  return { answered: true };
145
149
  }
@@ -149,22 +153,23 @@ export class Runner extends EventEmitter {
149
153
  // The run already produced its result and the CLI is only lingering on its way out:
150
154
  // there's nothing to wait for, so let it go and start the follow-up now.
151
155
  if (turn?.status !== 'running') {
152
- session.queue.push({ id: newId(), text, attachments });
156
+ session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
153
157
  handle.release();
154
158
  return { queued: session.queue.length > 0 };
155
159
  }
156
160
  // Interrupting needs an agent session to resume; before the agent reports one, queue.
157
161
  if (queue || !session.agentSessionId) {
158
- session.queue.push({ id: newId(), text, attachments });
162
+ session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
159
163
  this.#touch(session);
160
164
  return { queued: true, ahead: session.queue.length - 1 };
161
165
  }
162
166
  // Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
163
167
  const at = session.queue.findIndex((q) => !q.now);
164
- session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
168
+ session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...(mode && { mode }) });
165
169
  this.#interrupt(session);
166
170
  return { interrupted: true };
167
171
  }
172
+ if (mode) session.mode = mode;
168
173
  this.#startTurn(session, text, attachments);
169
174
  return { queued: false };
170
175
  }
@@ -252,6 +257,7 @@ export class Runner extends EventEmitter {
252
257
  let child = this.store.get(lead.children?.[slot]);
253
258
  if (child) {
254
259
  child.cwd = workdir(lead); // follows the lead if its worktree moved
260
+ child.mode = lead.mode; // and its mode, if a follow-up switched it
255
261
  this.#startTurn(child, prompt);
256
262
  } else {
257
263
  child = this.create({ prompt, cwd: workdir(lead), agent: m.agent, mode: lead.mode, model: m.model, effort: m.effort, parentId: lead.id, member: slot });
@@ -317,6 +323,7 @@ export class Runner extends EventEmitter {
317
323
  agent: session.agent,
318
324
  model: session.model,
319
325
  effort: session.effort || '',
326
+ mode: session.mode,
320
327
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
321
328
  startedAt: Date.now(),
322
329
  endedAt: null,
@@ -391,6 +398,7 @@ export class Runner extends EventEmitter {
391
398
  this.interrupting.delete(session.id);
392
399
  this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
393
400
  const next = session.queue.shift();
401
+ if (next?.mode) session.mode = next.mode;
394
402
  if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
395
403
  else this.#touch(session);
396
404
  };
@@ -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;
@@ -434,13 +450,13 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
434
450
  }
435
451
 
436
452
  /* ---------- feed ---------- */
437
- .feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 2px 10px; font-size: 13px; }
438
- .fh-status { display: flex; gap: 14px; }
453
+ .feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 0 10px; padding: 0 4px; font-size: 13px; }
439
454
  .feed-head[hidden] + .feed { margin-top: 26px; }
440
455
  .hs { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-2); }
441
456
  .hs i { width: 7px; height: 7px; border-radius: 50%; }
442
457
  .hs.working i { background: var(--accent); animation: pulse 1.6s infinite; }
443
458
  .hs.needs i { background: var(--warn); }
459
+ .hs.calm i { background: var(--good); }
444
460
 
445
461
  .f-group { margin-bottom: 22px; }
446
462
  .f-group h4 {
@@ -462,6 +478,11 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
462
478
  .toggle-done:hover { color: var(--ink-2); }
463
479
  .toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
464
480
  .f-group.done:not(.collapsed) .chev { transform: rotate(90deg); }
481
+ /* Folded groups are just a row of headings: stack them tight instead of leaving a section gap each. */
482
+ .f-group.collapsed { margin-bottom: 2px; }
483
+ .f-group.collapsed h4 { margin: 0; }
484
+ .f-group h4.toggle-done { width: fit-content; min-height: 24px; margin-left: -4px; padding: 0 8px; border-radius: 6px; transition: background 0.15s, color 0.15s; }
485
+ .f-group h4.toggle-done:hover { background: var(--bg-2); }
465
486
  .f-list { display: flex; flex-direction: column; gap: 6px; }
466
487
  .feed-empty { padding: 48px 0; text-align: center; color: var(--faint); font-size: 13.5px; }
467
488
 
@@ -1065,6 +1086,28 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1065
1086
  .trail-bar .link { font-size: 12px; }
1066
1087
  .trail-body { padding: 8px 0 0; animation: rise 0.4s var(--ease); }
1067
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; }
1068
1111
 
1069
1112
  /* ---------- per-item composer ---------- */
1070
1113
  .composer {
@@ -1082,6 +1125,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1082
1125
  .cq .att-x:hover { color: var(--bad); }
1083
1126
  .cq-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
1084
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); }
1085
1134
  .send { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--ink); color: var(--bg); transition: opacity 0.15s; }
1086
1135
  .send:hover { opacity: 0.86; }
1087
1136
  .send:disabled { opacity: 0.5; }
@@ -1200,6 +1249,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1200
1249
  .sx-ctl .input { width: 300px; }
1201
1250
  .sx-state { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
1202
1251
  .sx-state.bad { color: var(--bad); }
1252
+ #recheck.busy { cursor: progress; }
1253
+ #recheck.busy .i { animation: spin 0.8s linear infinite reverse; }
1203
1254
  .sx-usage .it-loading { grid-column: 1 / -1; }
1204
1255
  /* a view (Memory, Automations, Usage, Phone) opened inside Settings: no dialog chrome of its own */
1205
1256
  .modal.embedded { width: auto; max-height: none; overflow: visible; padding: 0; border: 0; border-radius: 0; box-shadow: none; background: none; transform: none; }
@@ -1476,6 +1527,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1476
1527
  .mc-adv summary { cursor: pointer; width: max-content; font-size: 12.5px; color: var(--muted); }
1477
1528
  .mc-adv[open] summary { margin-bottom: 8px; }
1478
1529
  .mc-check { margin-top: 8px; }
1530
+ /* Hosted inside Settings: undo its row styling for labels (centred, padded, bordered, input flexed to 0 tall). */
1531
+ .mc-form label { padding: 0; border-bottom: 0; }
1532
+ .mc-form .mc-field { align-items: stretch; justify-content: flex-start; gap: 5px; }
1533
+ .mc-form .mc-field > span { text-align: left; }
1534
+ .mc-form .mc-field > .input { flex: none; max-width: none; }
1535
+ .mc-form .mc-check { display: inline-flex; justify-content: flex-start; gap: 8px; width: max-content; }
1479
1536
  .mc-foot { display: flex; align-items: center; gap: 10px; }
1480
1537
  .mc-foot .hint { flex: 1; margin: 0 !important; font-size: 12px !important; }
1481
1538
  .mc-foot .primary { flex: none; }
@@ -1680,6 +1737,8 @@ body.electron.mac .topbar { padding-left: 88px; }
1680
1737
  .ph-ok { color: var(--good); }
1681
1738
  .ph-bad { color: var(--bad); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1682
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; }
1683
1742
 
1684
1743
  /* ---------- subagents ---------- */
1685
1744
  /* logo stack: the lead in front, its subagents tucked behind; they fan out on hover */
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>