wisp-agents 0.11.0 → 0.12.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,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.12.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - **Settings → Agents → Re-check** looks for harnesses and models again, on this computer and on every online machine. Install a CLI, sign in or add a provider (say a new opencode provider), come back to wisp and click it: the list, the model counts and the launcher's harness picker update without restarting wisp, and a toast says what changed ("opencode +12 models", "Codex found"). Each harness now shows how many models it has, and one that isn't installed says to install its CLI and sign in. Before, wisp kept the first detection until it restarted. API: `POST /api/agents/recheck`, or `GET /api/agents?fresh=1` for this computer only.
13
+
14
+ ### Changed
15
+
16
+ - The calm feed (nothing needs you) looks tidier. **Nothing needs you** gets a green dot and lines up with the **Done** and **Archived** headings, and folded groups now sit close together instead of a full section gap apart. Folded headings highlight on hover so they read as buttons.
17
+
18
+ ### Fixed
19
+
20
+ - In **Settings → Machines**, the add/edit machine form lays out properly again: labels sit left above their fields, and the **SSH host** and **Name** inputs are full height instead of squashed and centred. The Settings row style was leaking into it.
21
+
8
22
  ## [0.11.0] - 2026-10-18
9
23
 
10
24
  ### Added
@@ -206,7 +220,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
206
220
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
207
221
  - Older turns are folded, with a floating collapse-all button and back-to-top.
208
222
 
209
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.11.0...HEAD
223
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.12.0...HEAD
224
+ [0.12.0]: https://github.com/M0xoo/wisp/compare/v0.11.0...v0.12.0
210
225
  [0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
211
226
  [0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
212
227
  [0.9.0]: https://github.com/M0xoo/wisp/compare/v0.8.0...v0.9.0
package/README.md CHANGED
@@ -53,7 +53,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
53
53
  | 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
54
54
  | ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
55
55
  | ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
56
- | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.11.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
56
+ | 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.12.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
57
57
  | 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
58
58
  | 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there. |
59
59
  | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
@@ -125,7 +125,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question)
125
125
  wisp stop <id>
126
126
  wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
127
127
  wisp merge <id> # squash-merge its PR, fast-forward your local main
128
- wisp run release 0.11.0 # run a recipe; values fill its {params} (or version=0.11.0)
128
+ wisp run release 0.12.0 # run a recipe; values fill its {params} (or version=0.12.0)
129
129
  wisp recipes # list recipes
130
130
  wisp usage [--days 7] # spend and outcomes
131
131
  wisp mcp # MCP server for other agents and editors
@@ -305,7 +305,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
305
305
  Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
306
306
  ```
307
307
 
308
- Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.11.0` fills the fields in order, `wisp run release version=0.11.0` by name.
308
+ Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.12.0` fills the fields in order, `wisp run release version=0.12.0` by name.
309
309
 
310
310
  **Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
311
311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,10 @@ export const adapters = Object.fromEntries([claude, codex, opencode, cursor, ant
35
35
 
36
36
  const detected = new Map();
37
37
 
38
- export async function listAgents() {
38
+ // fresh: detect again even where a hit is cached (Settings → Agents → Re-check), so a newly
39
+ // signed-in provider's models or an upgraded CLI show up without restarting wisp.
40
+ export async function listAgents({ fresh = false } = {}) {
41
+ if (fresh) detected.clear();
39
42
  return Promise.all(
40
43
  Object.values(adapters).map(async (a) => {
41
44
  if (!detected.has(a.id)) detected.set(a.id, a.detect());
package/server/index.js CHANGED
@@ -429,7 +429,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
429
429
  }
430
430
  if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
431
431
 
432
- if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
432
+ if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents({ fresh: url.searchParams.has('fresh') }));
433
+ // Re-check: detect harnesses and models again, here and on every online machine.
434
+ if (root === 'agents' && id === 'recheck' && m === 'POST') {
435
+ const [agents] = await Promise.all([listAgents({ fresh: true }), machines.recheckAgents()]);
436
+ return send(res, 200, agents);
437
+ }
433
438
 
434
439
  // Bridge for the ask_user MCP server running inside an agent process.
435
440
  if (root === 'internal') {
@@ -156,6 +156,9 @@ export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen
156
156
  return [...uniq.values()];
157
157
  }
158
158
 
159
+ // What the UI needs of a machine's harness list.
160
+ const remoteAgent = ({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates });
161
+
159
162
  // Code hosts you push to, not computers to run agents on.
160
163
  const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
161
164
 
@@ -437,7 +440,7 @@ export class Machines extends EventEmitter {
437
440
  this.#step(c, 'tunnel', 'done', `localhost:${c.localPort} → ${m.port}`);
438
441
  const [agents, config] = await Promise.all([this.#raw(m, c, 'GET', '/agents').catch(() => []), this.#raw(m, c, 'GET', '/config').catch(() => ({}))]);
439
442
  const ready = agents.filter((a) => a.available);
440
- c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates })) };
443
+ c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(remoteAgent) };
441
444
  this.#step(c, 'agents', ready.length ? 'done' : 'warn', ready.length ? ready.map((a) => a.name).join(', ') : 'None found. Install a harness CLI there (claude, codex, opencode…) and sign in.');
442
445
  clearTimeout(c.readyTimer);
443
446
  Object.assign(c, { state: 'online', error: '', hint: null, attempt: 0, wasOnline: true, since: Date.now() });
@@ -447,6 +450,20 @@ export class Machines extends EventEmitter {
447
450
  this.emit('online', m);
448
451
  }
449
452
 
453
+ // Detect harnesses and models again on every online machine (after installing or signing in
454
+ // to something there). A machine that doesn't answer keeps what it had.
455
+ async recheckAgents() {
456
+ const online = this.list.map((m) => [m, this.conns.get(m.id)]).filter(([, c]) => c?.state === 'online');
457
+ await Promise.all(
458
+ online.map(async ([m, c]) => {
459
+ const agents = await this.#raw(m, c, 'GET', '/agents?fresh=1').catch(() => null);
460
+ if (!Array.isArray(agents) || this.conns.get(m.id) !== c) return;
461
+ c.remote = { ...c.remote, agents: agents.map(remoteAgent) };
462
+ }),
463
+ );
464
+ if (online.length) this.onChange();
465
+ }
466
+
450
467
  #fail(c, msg, hint, { keep = false } = {}) {
451
468
  if (this.conns.get(c.id) !== c) return;
452
469
  if (c.error && !keep) return;
package/server/openapi.js CHANGED
@@ -44,7 +44,8 @@ export function openapi({ version, port }) {
44
44
  responses: { 200: json({ type: 'object' }), 400: err[400] },
45
45
  },
46
46
  },
47
- '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
47
+ '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
+ '/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
49
  '/config': {
49
50
  get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
50
51
  patch: {
package/web/css/app.css CHANGED
@@ -434,13 +434,13 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
434
434
  }
435
435
 
436
436
  /* ---------- 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; }
437
+ .feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 0 10px; padding: 0 4px; font-size: 13px; }
439
438
  .feed-head[hidden] + .feed { margin-top: 26px; }
440
439
  .hs { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-2); }
441
440
  .hs i { width: 7px; height: 7px; border-radius: 50%; }
442
441
  .hs.working i { background: var(--accent); animation: pulse 1.6s infinite; }
443
442
  .hs.needs i { background: var(--warn); }
443
+ .hs.calm i { background: var(--good); }
444
444
 
445
445
  .f-group { margin-bottom: 22px; }
446
446
  .f-group h4 {
@@ -462,6 +462,11 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
462
462
  .toggle-done:hover { color: var(--ink-2); }
463
463
  .toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
464
464
  .f-group.done:not(.collapsed) .chev { transform: rotate(90deg); }
465
+ /* Folded groups are just a row of headings: stack them tight instead of leaving a section gap each. */
466
+ .f-group.collapsed { margin-bottom: 2px; }
467
+ .f-group.collapsed h4 { margin: 0; }
468
+ .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; }
469
+ .f-group h4.toggle-done:hover { background: var(--bg-2); }
465
470
  .f-list { display: flex; flex-direction: column; gap: 6px; }
466
471
  .feed-empty { padding: 48px 0; text-align: center; color: var(--faint); font-size: 13.5px; }
467
472
 
@@ -1200,6 +1205,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1200
1205
  .sx-ctl .input { width: 300px; }
1201
1206
  .sx-state { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
1202
1207
  .sx-state.bad { color: var(--bad); }
1208
+ #recheck.busy { cursor: progress; }
1209
+ #recheck.busy .i { animation: spin 0.8s linear infinite reverse; }
1203
1210
  .sx-usage .it-loading { grid-column: 1 / -1; }
1204
1211
  /* a view (Memory, Automations, Usage, Phone) opened inside Settings: no dialog chrome of its own */
1205
1212
  .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 +1483,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
1476
1483
  .mc-adv summary { cursor: pointer; width: max-content; font-size: 12.5px; color: var(--muted); }
1477
1484
  .mc-adv[open] summary { margin-bottom: 8px; }
1478
1485
  .mc-check { margin-top: 8px; }
1486
+ /* Hosted inside Settings: undo its row styling for labels (centred, padded, bordered, input flexed to 0 tall). */
1487
+ .mc-form label { padding: 0; border-bottom: 0; }
1488
+ .mc-form .mc-field { align-items: stretch; justify-content: flex-start; gap: 5px; }
1489
+ .mc-form .mc-field > span { text-align: left; }
1490
+ .mc-form .mc-field > .input { flex: none; max-width: none; }
1491
+ .mc-form .mc-check { display: inline-flex; justify-content: flex-start; gap: 8px; width: max-content; }
1479
1492
  .mc-foot { display: flex; align-items: center; gap: 10px; }
1480
1493
  .mc-foot .hint { flex: 1; margin: 0 !important; font-size: 12px !important; }
1481
1494
  .mc-foot .primary { flex: none; }
package/web/js/app.js CHANGED
@@ -269,7 +269,7 @@ function renderFeedHead(needs, working) {
269
269
  if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
270
270
  if (needs.length) parts.push(`<span class="hs needs"><i></i>${needs.length} need${needs.length === 1 ? 's' : ''} you</span>`);
271
271
  // Group headings already carry the counts; only say something when the feed is calm.
272
- $('#feedHead').innerHTML = parts.length ? '' : '<span class="fh-status"><span class="muted">Nothing needs you.</span></span>';
272
+ $('#feedHead').innerHTML = parts.length ? '' : '<span class="hs calm"><i></i>Nothing needs you</span>';
273
273
  $('#feedHead').hidden = !!parts.length;
274
274
  placeCollapseAll();
275
275
  }
@@ -4020,6 +4020,22 @@ const SETTINGS_PANES = [
4020
4020
  ];
4021
4021
  let settingsPane = 'general';
4022
4022
 
4023
+ // What a re-check turned up: harnesses found or gone, and model lists that grew or shrank.
4024
+ function recheckSummary(before, after) {
4025
+ const count = (a) => (a?.models || []).filter((m) => m.id).length;
4026
+ const notes = [];
4027
+ for (const a of after) {
4028
+ const was = before.get(a.id);
4029
+ if (a.available && !was?.available) notes.push(`<b>${esc(a.name)}</b> found`);
4030
+ else if (!a.available && was?.available) notes.push(`<b>${esc(a.name)}</b> not found`);
4031
+ else if (a.available && count(a) !== count(was)) {
4032
+ const d = count(a) - count(was);
4033
+ notes.push(`<b>${esc(a.name)}</b> ${d > 0 ? '+' : '−'}${Math.abs(d)} model${Math.abs(d) === 1 ? '' : 's'}`);
4034
+ } else if (a.available && a.version !== was?.version) notes.push(`<b>${esc(a.name)}</b> updated`);
4035
+ }
4036
+ return notes.length ? notes.join(' · ') : 'Up to date: nothing new';
4037
+ }
4038
+
4023
4039
  function openSettings(opts) {
4024
4040
  if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
4025
4041
  const d = S.config.defaults;
@@ -4056,9 +4072,10 @@ function openSettings(opts) {
4056
4072
  </div>
4057
4073
  </section>
4058
4074
  <section class="sx-pane" data-pane="agents">
4059
- <div class="sx-group">${S.agents
4060
- .map((a) => row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(a.available ? a.version : a.error || 'not found'), `<span class="sx-state ${a.available ? 'ok' : 'bad'}"><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`))
4061
- .join('')}</div>
4075
+ <div class="sx-group sx-agents"></div>
4076
+ <div class="sx-group">
4077
+ ${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
4078
+ </div>
4062
4079
  </section>
4063
4080
  <section class="sx-pane" data-pane="machines">
4064
4081
  <div class="sx-group">
@@ -4092,6 +4109,41 @@ function openSettings(opts) {
4092
4109
  </div>`);
4093
4110
  const close = overlay(node);
4094
4111
  $('.close', node).onclick = close;
4112
+ const paintAgents = () => {
4113
+ $('.sx-agents', node).innerHTML = S.agents.length
4114
+ ? S.agents.map((a) => {
4115
+ const n = (a.models || []).filter((m) => m.id).length;
4116
+ const desc = a.available ? [a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · ') : `Install the ${a.name} CLI and sign in, then re-check`;
4117
+ return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(desc), `<span class="sx-state ${a.available ? 'ok' : 'bad'}" ${a.error ? `title="${esc(a.error)}"` : ''}><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`);
4118
+ }).join('')
4119
+ : '<div class="sx-row"><div class="it-loading"><span class="orb tiny"></span>Looking for harnesses…</div></div>';
4120
+ const missing = S.agents.filter((a) => !a.available).length;
4121
+ const dot = $('.sx-nav [data-pane="agents"] .dot', node);
4122
+ if (dot && !missing) dot.remove();
4123
+ if (!dot && missing) $('.sx-nav [data-pane="agents"]', node).insertAdjacentHTML('beforeend', `<i class="dot bad" title="${missing} not found"></i>`);
4124
+ const online = S.machines.filter((m) => m.state === 'online').length;
4125
+ $('.recheck-hint', node).textContent = `After installing a CLI, signing in or adding a provider, look for harnesses and models again${online ? `, here and on ${online} machine${online === 1 ? '' : 's'}` : ''}`;
4126
+ };
4127
+ paintAgents();
4128
+ node.paintAgents = paintAgents;
4129
+ $('#recheck', node).onclick = async (e) => {
4130
+ const btn = e.currentTarget;
4131
+ btn.disabled = true;
4132
+ btn.classList.add('busy');
4133
+ const before = new Map(S.agents.map((a) => [a.id, a]));
4134
+ try {
4135
+ S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
4136
+ if (!node.isConnected) return;
4137
+ paintAgents();
4138
+ $('#launcher')?.placesChanged?.();
4139
+ toast(recheckSummary(before, S.agents));
4140
+ } catch (err) {
4141
+ toast(esc(err.message), { tone: 'bad' });
4142
+ } finally {
4143
+ btn.disabled = false;
4144
+ btn.classList.remove('busy');
4145
+ }
4146
+ };
4095
4147
  const pick = (id, focus = false) => {
4096
4148
  settingsPane = id;
4097
4149
  const p = SETTINGS_PANES.find((x) => x.id === id);
@@ -4820,6 +4872,7 @@ window.addEventListener('focus', () => {
4820
4872
  (async () => {
4821
4873
  [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
4822
4874
  renderLauncher();
4875
+ $('.settings-modal')?.paintAgents?.();
4823
4876
  connect();
4824
4877
  route();
4825
4878
  $('#prompt')?.focus();