wisp-agents 0.8.0 → 0.9.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/web/js/app.js CHANGED
@@ -16,6 +16,7 @@ const S = {
16
16
  focus: null, // id of the keyboard-focused item
17
17
  config: { defaults: {}, projects: [] },
18
18
  agents: [],
19
+ machines: [], // other computers over SSH: GET /api/machines, kept fresh by the 'machines' event
19
20
  filter: load('wisp.filter', 'all'),
20
21
  showDone: load('wisp.showDone', false),
21
22
  showArchived: false,
@@ -72,6 +73,7 @@ const HARNESS_LOGO = {
72
73
  opencode: { file: 'opencode', mono: true },
73
74
  cursor: { file: 'cursor', mono: true },
74
75
  antigravity: { file: 'antigravity-color' },
76
+ grok: { file: 'grok', mono: true },
75
77
  };
76
78
  function harnessLogo(agent) {
77
79
  const l = HARNESS_LOGO[agent];
@@ -332,6 +334,7 @@ function renderHead(n, s) {
332
334
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
333
335
  ${s.pr ? prPill(s.pr) : ''}
334
336
  ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
337
+ ${s.machine ? machinePill(s.machine) : ''}
335
338
  <span class="q-proj">${esc(s.project)}</span>
336
339
  <span class="q-time">${ago(s.updatedAt)}</span>
337
340
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
@@ -351,6 +354,7 @@ function renderRunHead(n, s, { open, live, pill }) {
351
354
  ${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
352
355
  </span>
353
356
  <span class="it-side">
357
+ ${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
354
358
  ${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
355
359
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
356
360
  ${s.pr ? prPill(s.pr) : ''}
@@ -376,7 +380,8 @@ function taskItem(t) {
376
380
  }
377
381
  g.lead = t.id;
378
382
  const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
379
- const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished` : t.state === 'done' ? 'Done' : 'All finished · pick one to keep';
383
+ const places = new Set(t.runs.map((r) => r.machine?.id || '')).size;
384
+ const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep';
380
385
  g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
381
386
  g.head.innerHTML = `
382
387
  <span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
@@ -386,7 +391,7 @@ function taskItem(t) {
386
391
  </span>
387
392
  <span class="it-side">
388
393
  <button class="ghost xs" data-a="compare" title="Side by side: time, cost, changes, tests and each presentation"><b class="vs-mark">vs</b><span>Compare</span></button>
389
- <span class="q-proj">${esc(t.project)}</span>
394
+ ${places > 1 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
390
395
  <span class="q-time">${ago(t.updatedAt)}</span>
391
396
  ${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
392
397
  </span>`;
@@ -574,7 +579,28 @@ function autosize(ta, max = 340) {
574
579
 
575
580
  // --- launcher ----------------------------------------------------------------------
576
581
 
577
- let launchProject = load('wisp.launchProject', null);
582
+ let launchProject = load('wisp.launchProject', null); // the project picked in the first place below
583
+ // Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
584
+ // each in the project picked for it (launchPicks[place]), and show up as one task.
585
+ let launchWhere = load('wisp.launchWhere', ['']);
586
+ let launchPicks = load('wisp.launchPicks', {});
587
+ const placeOf = (p) => p?.machine || '';
588
+ const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
589
+ const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
590
+ const projectsAt = (k) => S.config.projects.filter((p) => placeOf(p) === k).sort(recentFirst);
591
+ const saveWhere = () => (save('wisp.launchWhere', launchWhere), save('wisp.launchPicks', launchPicks), save('wisp.launchProject', launchProject));
592
+ function syncWhere() {
593
+ launchWhere = launchWhere.filter((k, i) => (!k || machineById(k)) && launchWhere.indexOf(k) === i);
594
+ if (!launchWhere.length) launchWhere = [''];
595
+ // Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
596
+ const f = S.filter !== 'all' && projectById(S.filter);
597
+ if (f) {
598
+ if (!launchWhere.includes(placeOf(f))) launchWhere = [placeOf(f)];
599
+ launchPicks[placeOf(f)] = f.id;
600
+ } else if (projectById(launchProject) && !Object.values(launchPicks).includes(launchProject)) launchPicks[placeOf(projectById(launchProject))] ??= launchProject;
601
+ for (const k of launchWhere) if (!projectsAt(k).some((p) => p.id === launchPicks[k])) launchPicks[k] = projectsAt(k)[0]?.id || null;
602
+ launchProject = launchPicks[launchWhere[0]] || null;
603
+ }
578
604
 
579
605
  // The model is remembered per harness (ids aren't portable between them).
580
606
  function savedModel(id) {
@@ -607,6 +633,7 @@ function renderLauncher() {
607
633
  <button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
608
634
  <button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
609
635
  </div>
636
+ <button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
610
637
  <button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
611
638
  <span class="grow"></span>
612
639
  <button class="tog-btn" id="wtBtn"></button>
@@ -636,6 +663,8 @@ function renderLauncher() {
636
663
  ta.focus();
637
664
  });
638
665
  $('#attach', box).onclick = tray.pick;
666
+ $('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
667
+ paintWhereBtn();
639
668
 
640
669
  // Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
641
670
  // spelled out when it's Plan or Full auto so you can't miss it.
@@ -742,24 +771,45 @@ function renderLauncher() {
742
771
  const launch = async () => {
743
772
  const prompt = ta.value.trim();
744
773
  if (!prompt) return ta.focus();
745
- const project = projectById(launchProject);
746
- if (!project) return openProjects({ focusAdd: true });
774
+ // One run per place picked under "Run on", each in the project picked for it.
775
+ const targets = launchWhere.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launchPicks[k]) }));
776
+ const unset = targets.find((t) => !t.project);
777
+ if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
778
+ const off = targets.find((t) => t.m && t.m.state !== 'online');
779
+ if (off) return offlineToast(off.k);
780
+ const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
781
+ for (const t of targets) {
782
+ const missing = t.m?.remote?.agents && wanted.find((id) => !t.m.remote.agents.some((x) => x.id === id && x.available));
783
+ if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(t.m.name)}</b>`, { tone: 'bad' });
784
+ }
747
785
  const btn = $('#launch', box);
748
786
  btn.disabled = true;
749
787
  btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
750
788
  try {
751
789
  const attachments = await tray.ids();
752
- const s = await api('/sessions', {
753
- method: 'POST',
754
- body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments, worktree: S.config.defaults.worktree === 'off' && !compare ? false : undefined, compare: compare ? [compare] : undefined, subagents: team.length && canLead(agent?.id) ? team : undefined },
790
+ // Several places: one shared groupId, so the runs land as one task with a row each.
791
+ const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
792
+ const results = await Promise.allSettled(
793
+ targets.map((t) =>
794
+ api('/sessions', {
795
+ method: 'POST',
796
+ body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments, worktree: S.config.defaults.worktree === 'off' && !compare ? false : undefined, compare: compare ? [compare] : undefined, subagents: team.length && canLead(agent?.id) ? team : undefined },
797
+ }),
798
+ ),
799
+ );
800
+ results.forEach((r, i) => {
801
+ if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
755
802
  });
756
- for (const x of s.group || [s]) S.sessions.set(x.id, x);
803
+ const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
804
+ if (!started.length) return;
805
+ for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
806
+ const s = started[0];
757
807
  if (compare) (compare = null), paintCompare();
758
808
  ta.value = '';
759
809
  S.drafts.delete('home');
760
810
  tray.clear();
761
811
  ta.dispatchEvent(new Event('input'));
762
- if (S.filter !== 'all' && S.filter !== s.projectId) {
812
+ if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
763
813
  S.filter = 'all';
764
814
  save('wisp.filter', S.filter);
765
815
  }
@@ -905,10 +955,10 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
905
955
  <b>×${copiesOf(m)}</b>
906
956
  <button class="tp-inc" aria-label="One copy more" ${copiesOf(m) >= MAX_COPIES || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}</button>
907
957
  </span>
908
- <label class="tp-name" title="What the lead calls it"><span>@</span><input value="${esc(m.name || '')}" placeholder="${esc(named[i].name)}" maxlength="24" spellcheck="false" autocomplete="off"></label>
958
+ <label class="field sm mono tp-name" title="What the lead calls it"><span>@</span><input value="${esc(m.name || '')}" placeholder="${esc(named[i].name)}" maxlength="24" spellcheck="false" autocomplete="off"></label>
909
959
  <button class="icon-btn tp-del" title="Remove">${icon('x')}</button>
910
960
  </div>
911
- <label class="tp-use"><span>Use for</span><input value="${esc(m.use || '')}" placeholder="e.g. image and UI work · research · writing tests" maxlength="300" spellcheck="false" autocomplete="off"></label>
961
+ <label class="field sm tp-use"><span>Use for</span><input value="${esc(m.use || '')}" placeholder="e.g. image and UI work · research · writing tests" maxlength="300" spellcheck="false" autocomplete="off"></label>
912
962
  </div>`,
913
963
  )
914
964
  .join('')
@@ -931,7 +981,7 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
931
981
  // Save: name it; an existing preset's name updates that preset.
932
982
  const askName = () => {
933
983
  const cur = (S.config.teams || []).find((x) => x.id === preset);
934
- foot.innerHTML = `<form class="tp-saveform"><input placeholder="Preset name, e.g. Review crew" value="${esc(cur?.name || '')}" maxlength="60"><button class="primary sm">Save</button><button type="button" class="link xs tp-cancel">Cancel</button></form>`;
984
+ foot.innerHTML = `<form class="tp-saveform"><input class="input sm" placeholder="Preset name, e.g. Review crew" value="${esc(cur?.name || '')}" maxlength="60"><button class="primary sm">Save</button><button type="button" class="link xs tp-cancel">Cancel</button></form>`;
935
985
  const input = $('input', foot);
936
986
  input.focus();
937
987
  input.select();
@@ -1226,23 +1276,118 @@ function modeMenu(anchor, current, pick) {
1226
1276
  function renderLaunchChips() {
1227
1277
  const box = $('#launcher .l-projects');
1228
1278
  if (!box) return;
1229
- const projects = S.config.projects.slice().sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt));
1230
- if (S.filter !== 'all' && projectById(S.filter)) launchProject = S.filter;
1231
- if (!projectById(launchProject)) launchProject = projects[0]?.id || null;
1232
- box.innerHTML = `
1233
- <span class="l-in">in</span>
1234
- ${projects.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launchProject ? 'on' : ''}" data-id="${p.id}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('')}
1235
- <button class="lp-chip add" id="lpAdd">${icon('folder')}${projects.length ? 'Other folder…' : 'Choose a project folder…'}</button>`;
1236
- $$('.lp-chip[data-id]', box).forEach((b) =>
1237
- b.addEventListener('click', () => {
1238
- launchProject = b.dataset.id;
1239
- save('wisp.launchProject', launchProject);
1240
- $$('.lp-chip', box).forEach((x) => x.classList.toggle('on', x === b));
1279
+ syncWhere();
1280
+ const multi = launchWhere.length > 1;
1281
+ // One group per place: its name (unless it's only this computer), its projects, a way to add one.
1282
+ const group = (k) => {
1283
+ const m = k && machineById(k);
1284
+ const list = projectsAt(k);
1285
+ const off = m && m.state !== 'online';
1286
+ const label = k || multi ? `<span class="lp-gl ${off ? 'off' : ''}" title="${esc(k ? `${m?.target} · ${MACHINE_STATE[m?.state] || ''}` : 'This computer')}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}${off ? `<button class="link xs" data-connect="${k}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button>` : ''}</span>` : '';
1287
+ const chips = list.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launchPicks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
1288
+ const add = list.length
1289
+ ? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi ? '' : 'Other folder…'}</button>`
1290
+ : `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a project folder…'}</button>`;
1291
+ return `<span class="lp-group">${label}${chips}${add}</span>`;
1292
+ };
1293
+ box.innerHTML = `<span class="l-in">in</span>${launchWhere.map(group).join('')}`;
1294
+ box.onclick = (e) => {
1295
+ const chip = e.target.closest('.lp-chip[data-id]');
1296
+ if (chip) {
1297
+ launchPicks[chip.dataset.k] = chip.dataset.id;
1298
+ if (chip.dataset.k === launchWhere[0]) launchProject = chip.dataset.id;
1299
+ saveWhere();
1300
+ $$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
1241
1301
  renderLaunchRecipes();
1242
- }),
1243
- );
1244
- $('#lpAdd', box).onclick = () => openProjects({ focusAdd: true });
1302
+ return;
1303
+ }
1304
+ const add = e.target.closest('[data-add]');
1305
+ if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
1306
+ const conn = e.target.closest('[data-connect]');
1307
+ if (conn) connectMachine(conn.dataset.connect);
1308
+ };
1245
1309
  renderLaunchRecipes();
1310
+ paintWhereBtn();
1311
+ }
1312
+
1313
+ // "Run on": this computer and/or machines. The button says where; its menu picks one or several.
1314
+ function paintWhereBtn() {
1315
+ const b = $('#whereBtn');
1316
+ if (!b) return;
1317
+ const n = launchWhere.length;
1318
+ const remote = n > 1 || !!launchWhere[0];
1319
+ b.classList.toggle('on', remote);
1320
+ b.classList.toggle('bare', !S.machines.length && !remote);
1321
+ b.innerHTML = `${icon(remote ? 'server' : 'laptop')}<span>${esc(n > 1 ? `${n} places` : placeName(launchWhere[0]))}</span>${icon('chevron', 'chev')}`;
1322
+ b.title = n > 1 ? `Runs in parallel on ${launchWhere.map(placeName).join(', ')}` : S.machines.length ? `Runs on ${placeName(launchWhere[0])}. Pick several to run in parallel.` : 'Runs on this computer. Add a machine to run on other computers too.';
1323
+ }
1324
+
1325
+ function whereMenu(anchor) {
1326
+ if ($('.menu.where-menu')) return $('.menu.where-menu').close();
1327
+ const box = el(`<div class="menu where-menu" role="menu"></div>`);
1328
+ const paint = () => {
1329
+ const row = (k, m) => {
1330
+ const on = launchWhere.includes(k);
1331
+ const st = m ? m.state : 'online';
1332
+ const ready = m ? (m.remote?.agents || []).filter((a) => a.available) : [];
1333
+ return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
1334
+ <span class="wm-check">${icon('check')}</span>
1335
+ <span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
1336
+ <span class="wm-name"><b>${esc(placeName(k))}</b><small>${m ? esc(st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) : `${projectsAt('').length} project${projectsAt('').length === 1 ? '' : 's'}`}</small></span>
1337
+ ${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
1338
+ <span class="wm-only" data-only="${k}" title="Only here">only</span>
1339
+ </button>`;
1340
+ };
1341
+ box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel</small></div>
1342
+ ${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
1343
+ <div class="wm-sep"></div>
1344
+ <button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
1345
+ ${S.machines.length ? `<button data-a="manage">${icon('settings')}Manage machines</button>` : ''}`;
1346
+ };
1347
+ const set = (list) => {
1348
+ launchWhere = list.length ? list : [''];
1349
+ // A machine picked while disconnected starts connecting; you asked to run there.
1350
+ for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
1351
+ renderLaunchChips();
1352
+ paint();
1353
+ saveWhere();
1354
+ };
1355
+ box.onclick = (e) => {
1356
+ const only = e.target.closest('[data-only]');
1357
+ if (only) return set([only.dataset.only]);
1358
+ const r = e.target.closest('.wm-row');
1359
+ if (r) {
1360
+ const k = r.dataset.k;
1361
+ return set(launchWhere.includes(k) ? launchWhere.filter((x) => x !== k) : [...launchWhere, k]);
1362
+ }
1363
+ const a = e.target.closest('[data-a]')?.dataset.a;
1364
+ if (!a) return;
1365
+ shut();
1366
+ openMachines({ focusAdd: a === 'add' });
1367
+ };
1368
+ const shut = () => {
1369
+ box.remove();
1370
+ anchor.setAttribute('aria-expanded', 'false');
1371
+ document.removeEventListener('mousedown', outside);
1372
+ document.removeEventListener('keydown', esc_, true);
1373
+ };
1374
+ const outside = (ev) => {
1375
+ if (!box.contains(ev.target) && !anchor.contains(ev.target)) shut();
1376
+ };
1377
+ const esc_ = (ev) => {
1378
+ if (ev.key === 'Escape') (ev.stopPropagation(), shut());
1379
+ };
1380
+ box.close = shut;
1381
+ box.refresh = paint;
1382
+ paint();
1383
+ document.body.append(box);
1384
+ anchor.setAttribute('aria-expanded', 'true');
1385
+ const r = anchor.getBoundingClientRect();
1386
+ const h = box.offsetHeight;
1387
+ box.style.left = `${Math.min(r.left, innerWidth - box.offsetWidth - 8)}px`;
1388
+ box.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
1389
+ setTimeout(() => document.addEventListener('mousedown', outside));
1390
+ document.addEventListener('keydown', esc_, true);
1246
1391
  }
1247
1392
 
1248
1393
  // Recipes for the picked project (and the ones tied to none), one click from the launcher.
@@ -1259,9 +1404,7 @@ function renderLaunchRecipes() {
1259
1404
  ${list.map((r) => `<button class="lp-chip rc" data-recipe="${esc(r.id)}" title="${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 200))}">${esc(r.name)}${recipeParams(r.prompt).length ? '<i class="rc-f">…</i>' : ''}</button>`).join('')}
1260
1405
  ${list.length ? '' : `<span class="muted xs lr-empty">${hidden ? `None for this project · ${hidden} elsewhere` : 'Prompts you send again and again, one click away'}</span>`}
1261
1406
  <button class="lp-chip add" id="lrSave" hidden>${icon('plus')}Save as recipe</button>
1262
- <button class="lp-chip add" id="lrNew">${icon('plus')}New</button>
1263
- <span class="grow"></span>
1264
- <button class="link xs" id="lrAll">Manage</button>`;
1407
+ <button class="lp-chip add" id="lrNew">${icon('plus')}New</button>`;
1265
1408
  row.onclick = (e) => {
1266
1409
  const b = e.target.closest('button');
1267
1410
  if (!b) return;
@@ -1272,7 +1415,6 @@ function renderLaunchRecipes() {
1272
1415
  const draft = { name: '', prompt: '', cwd: path || '', mode: $('#launcher').mode?.() };
1273
1416
  if (b.id === 'lrSave') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { ...draft, prompt: $('#prompt').value.trim() } } });
1274
1417
  if (b.id === 'lrNew') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft } });
1275
- if (b.id === 'lrAll') openAutomations({ tab: 'recipes' });
1276
1418
  };
1277
1419
  paintSaveRecipe();
1278
1420
  }
@@ -1347,8 +1489,8 @@ function renderProjects() {
1347
1489
  };
1348
1490
  const chip = (id, name, color) => {
1349
1491
  const st = stats(id);
1350
- return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectById(id)?.path || '')}">
1351
- ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
1492
+ return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
1493
+ ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
1352
1494
  </button>`;
1353
1495
  };
1354
1496
  $('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
@@ -1690,7 +1832,7 @@ function renderAskCard(s) {
1690
1832
  <p class="q-text">${esc(a.question)}</p>
1691
1833
  ${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
1692
1834
  ${a.options?.length ? `<div class="q-opts">${a.options.map((o, i) => `<button class="opt" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>` : ''}
1693
- <form class="q-own"><input placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
1835
+ <form class="q-own"><input class="input" placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
1694
1836
  </div>`);
1695
1837
  const answer = async (text) => {
1696
1838
  if (!text.trim()) return;
@@ -2068,7 +2210,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
2068
2210
  .join('')}
2069
2211
  </div>
2070
2212
  <div class="pk-main">
2071
- <label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
2213
+ <label class="field pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
2072
2214
  <div class="pk-list" role="listbox" aria-label="Model"></div>
2073
2215
  <div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
2074
2216
  </div>
@@ -2456,9 +2598,20 @@ const EDITORS = {
2456
2598
  idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
2457
2599
  };
2458
2600
 
2601
+ // VS Code and its forks open a file on a machine through their Remote - SSH extension.
2602
+ const REMOTE_EDITORS = new Set(['vscode', 'cursor', 'windsurf']);
2603
+
2459
2604
  function openInEditor(s, p, line) {
2460
2605
  const editor = S.config.defaults.editor || 'vscode';
2461
2606
  const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
2607
+ if (s.machine) {
2608
+ if (!REMOTE_EDITORS.has(editor)) {
2609
+ copy(abs);
2610
+ return toast(`Copied path ${esc(abs)} (on ${esc(s.machine.name)})`);
2611
+ }
2612
+ location.href = `${editor}://vscode-remote/ssh-remote+${encodeURIComponent(s.machine.target)}${abs}${line ? `:${line}` : ''}`;
2613
+ return;
2614
+ }
2462
2615
  if (editor === 'none' || !EDITORS[editor]) {
2463
2616
  copy(abs);
2464
2617
  return toast(`Copied path ${esc(abs)}`);
@@ -2490,12 +2643,14 @@ function reveal(id) {
2490
2643
 
2491
2644
  const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
2492
2645
 
2493
- async function openProjects({ focusAdd = false } = {}) {
2646
+ // where: a machine id to add folders on that computer (server/machines.js), else this one.
2647
+ async function openProjects({ focusAdd = false, machine = null, host } = {}) {
2494
2648
  const node = el(`
2495
2649
  <div class="modal projects-modal">
2496
2650
  <header><h2>Projects</h2><button class="icon-btn close">${icon('x')}</button></header>
2651
+ <div class="mem-tabs where"></div>
2497
2652
  <div class="add-row">
2498
- <div class="path-input">
2653
+ <div class="field lg mono path-input">
2499
2654
  ${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
2500
2655
  <input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
2501
2656
  <div class="suggest" hidden></div>
@@ -2503,23 +2658,55 @@ async function openProjects({ focusAdd = false } = {}) {
2503
2658
  ${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
2504
2659
  <button class="primary" id="padd">Add</button>
2505
2660
  </div>
2506
- <p class="hint">Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.</p>
2661
+ <p class="hint"></p>
2507
2662
  <div class="plist" id="plist"></div>
2508
2663
  <div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
2509
2664
  </div>`);
2510
- const close = overlay(node);
2511
- $('.close', node).onclick = close;
2665
+ present(node, host);
2512
2666
  const input = $('#ppath', node);
2513
2667
  const sug = $('.suggest', node);
2668
+ let where = machine && machineById(machine) ? machine : null;
2669
+ const here = () => machineById(where);
2670
+ const home = () => (where ? here()?.remote?.home : S.config.home) || '';
2671
+ const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
2672
+
2673
+ const paintWhere = () => {
2674
+ const tabs = $('.where', node);
2675
+ tabs.innerHTML = `<button data-w="" class="${where ? '' : 'on'}">${icon('folder')}This computer</button>${S.machines
2676
+ .map((m) => `<button data-w="${m.id}" class="${where === m.id ? 'on' : ''}" title="${esc(m.target)} · ${esc(MACHINE_STATE[m.state] || m.state)}">${icon('server')}${esc(m.name)}<i class="dot ${m.state === 'online' ? 'ok' : m.state === 'error' ? 'bad' : ''}"></i></button>`)
2677
+ .join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
2678
+ const m = here();
2679
+ input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
2680
+ $$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
2681
+ $('.hint', node).innerHTML = m
2682
+ ? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
2683
+ : 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
2684
+ };
2685
+ $('.where', node).addEventListener('click', (e) => {
2686
+ const b = e.target.closest('[data-w]');
2687
+ if (!b) return;
2688
+ if (b.dataset.w === '+') {
2689
+ openMachines({ focusAdd: true });
2690
+ return;
2691
+ }
2692
+ where = b.dataset.w || null;
2693
+ sug.hidden = true;
2694
+ paintWhere();
2695
+ list();
2696
+ discover();
2697
+ input.focus();
2698
+ });
2514
2699
 
2515
2700
  const add = async (p) => {
2516
2701
  try {
2517
- const proj = await api('/projects', { method: 'POST', body: { path: p } });
2702
+ const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
2518
2703
  await loadConfig();
2519
- launchProject = proj.id;
2520
- save('wisp.launchProject', launchProject);
2704
+ launchPicks[placeOf(proj)] = proj.id;
2705
+ if (!launchWhere.includes(placeOf(proj))) launchWhere = [placeOf(proj)];
2706
+ if (placeOf(proj) === launchWhere[0]) launchProject = proj.id;
2707
+ saveWhere();
2521
2708
  renderLaunchChips();
2522
- toast(`Added <b>${esc(proj.name)}</b>`);
2709
+ toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
2523
2710
  input.value = '';
2524
2711
  sug.hidden = true;
2525
2712
  list();
@@ -2528,7 +2715,9 @@ async function openProjects({ focusAdd = false } = {}) {
2528
2715
  toast(esc(err.message), { tone: 'bad' });
2529
2716
  }
2530
2717
  };
2531
- $('#padd', node).onclick = () => input.value.trim() && add(input.value.trim());
2718
+ $('#padd', node).onclick = () => {
2719
+ if (input.value.trim()) add(input.value.trim());
2720
+ };
2532
2721
  const browse = async () => {
2533
2722
  const dir = await window.wispDesktop.pickFolder();
2534
2723
  if (dir) add(dir);
@@ -2542,10 +2731,10 @@ async function openProjects({ focusAdd = false } = {}) {
2542
2731
  t = setTimeout(async () => {
2543
2732
  const q = input.value;
2544
2733
  if (!q) return (sug.hidden = true);
2545
- const dirs = await api(`/fs?q=${encodeURIComponent(q)}`).catch(() => []);
2734
+ const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
2546
2735
  sel = -1;
2547
2736
  sug.hidden = !dirs.length;
2548
- sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(d.replace(S.config.home, '~'))}</button>`).join('');
2737
+ sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(tilde(d))}</button>`).join('');
2549
2738
  }, 120);
2550
2739
  });
2551
2740
  input.addEventListener('keydown', (e) => {
@@ -2556,7 +2745,7 @@ async function openProjects({ focusAdd = false } = {}) {
2556
2745
  opts.forEach((o, i) => o.classList.toggle('on', i === sel));
2557
2746
  } else if (e.key === 'Tab' && opts.length) {
2558
2747
  e.preventDefault();
2559
- input.value = (opts[Math.max(0, sel)].dataset.p + '/').replace(S.config.home, '~');
2748
+ input.value = tilde(opts[Math.max(0, sel)].dataset.p + '/');
2560
2749
  input.dispatchEvent(new Event('input'));
2561
2750
  } else if (e.key === 'Enter') {
2562
2751
  e.preventDefault();
@@ -2570,20 +2759,20 @@ async function openProjects({ focusAdd = false } = {}) {
2570
2759
 
2571
2760
  const list = () => {
2572
2761
  const box = $('#plist', node);
2573
- const projects = S.config.projects;
2762
+ const projects = S.config.projects.filter((p) => (p.machine || null) === where);
2574
2763
  box.innerHTML = projects.length
2575
2764
  ? projects
2576
2765
  .map((p) => {
2577
2766
  const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
2578
2767
  return `<div class="prow c-${p.color}" data-id="${p.id}">
2579
2768
  <button class="swatch" title="Change colour"><i class="p-dot"></i></button>
2580
- <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(p.path.replace(S.config.home, '~'))}</span></div>
2769
+ <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
2581
2770
  <span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
2582
2771
  <button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
2583
2772
  </div>`;
2584
2773
  })
2585
2774
  .join('')
2586
- : '<p class="muted">No projects yet. Add a folder above, or pick one below.</p>';
2775
+ : `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
2587
2776
  $$('.prow', box).forEach((row) => {
2588
2777
  const id = row.dataset.id;
2589
2778
  const p = projectById(id);
@@ -2603,18 +2792,35 @@ async function openProjects({ focusAdd = false } = {}) {
2603
2792
 
2604
2793
  const discover = async () => {
2605
2794
  const box = $('#discover', node);
2606
- const found = await api('/projects/discover').catch(() => []);
2607
- box.innerHTML = found.length
2608
- ? `<h4>Found on this machine <span class="muted">· git repos</span></h4><div class="found">${found
2609
- .map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(f.path.replace(S.config.home, '~').replace(/\/[^/]+$/, ''))}</span></button>`)
2610
- .join('')}</div>`
2611
- : '';
2795
+ const m = here();
2796
+ const at = where;
2612
2797
  box.onclick = (e) => {
2613
2798
  const b = e.target.closest('.found-item');
2614
2799
  if (b) add(b.dataset.p);
2800
+ if (e.target.closest('[data-a="connect"]')) connectMachine(at);
2615
2801
  };
2802
+ if (m && m.state !== 'online') {
2803
+ box.innerHTML = `<p class="muted">${esc(m.name)} is ${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : `offline. <button class="link" data-a="connect">Connect</button>`}</p>`;
2804
+ return;
2805
+ }
2806
+ box.innerHTML = `<h4>Found on ${m ? esc(m.name) : 'this machine'}</h4><p class="muted">Looking for git repos…</p>`;
2807
+ const found = await api(m ? `/machines/${m.id}/discover` : '/projects/discover').catch(() => []);
2808
+ if (where !== at) return; // switched tabs meanwhile
2809
+ box.innerHTML = found.length
2810
+ ? `<h4>Found on ${m ? esc(m.name) : 'this machine'} <span class="muted">· git repos</span></h4><div class="found">${found
2811
+ .map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(tilde(f.path).replace(/\/[^/]+$/, ''))}</span></button>`)
2812
+ .join('')}</div>`
2813
+ : '';
2814
+ };
2815
+ let wasOnline = here()?.state === 'online';
2816
+ node.refresh = () => {
2817
+ paintWhere();
2818
+ const on = here()?.state === 'online';
2819
+ if (on !== wasOnline) discover();
2820
+ wasOnline = on;
2616
2821
  };
2617
2822
 
2823
+ paintWhere();
2618
2824
  list();
2619
2825
  discover();
2620
2826
  if (focusAdd) setTimeout(() => input.focus(), 60);
@@ -2677,9 +2883,9 @@ async function shipDialog(id) {
2677
2883
  <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
2678
2884
  <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
2679
2885
  <span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
2680
- ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input name="title" spellcheck="false" maxlength="100"></label>` : ''}
2681
- ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea name="body" rows="9" spellcheck="false"></textarea></label>
2682
- <label class="ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
2886
+ ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
2887
+ ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
2888
+ <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
2683
2889
  ${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
2684
2890
  <div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
2685
2891
  </div>`);
@@ -2917,8 +3123,8 @@ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
2917
3123
 
2918
3124
  const recipeForm = (d) => {
2919
3125
  const f = el(`<form class="au-form">
2920
- <label><span>Name</span><input name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
2921
- <label class="stack"><span>Prompt <small class="muted">· <code>{version}</code> becomes a field</small></span><textarea name="prompt" rows="7" spellcheck="false" placeholder="Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}…"></textarea></label>
3126
+ <label><span>Name</span><input class="input" name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
3127
+ <label class="stack"><span>Prompt <small class="muted">· <code>{version}</code> becomes a field</small></span><textarea class="textarea mono" name="prompt" rows="7" spellcheck="false" placeholder="Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}…"></textarea></label>
2922
3128
  <p class="au-params muted xs"></p>
2923
3129
  <label><span>Project</span><select name="cwd">${projectOptions(d.cwd, { any: 'Any project (picked when run)' })}</select></label>
2924
3130
  <label><span>Mode</span><select name="mode">${modeOptions(d.mode, { inherit: 'Launcher default' })}</select></label>
@@ -2954,15 +3160,15 @@ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
2954
3160
  d.kind ||= 'schedule';
2955
3161
  const f = el(`<form class="au-form">
2956
3162
  <div class="au-kinds">${TRIGGER_KINDS.map((k) => `<button type="button" data-kind="${k.kind}" class="${k.kind === d.kind ? 'on' : ''}">${icon(k.icon)}<b>${k.label}</b><small>${k.hint}</small></button>`).join('')}</div>
2957
- <label><span>Name</span><input name="name" maxlength="60" spellcheck="false"></label>
3163
+ <label><span>Name</span><input class="input" name="name" maxlength="60" spellcheck="false"></label>
2958
3164
  <label><span>Project</span><select name="cwd">${projectOptions(d.cwd || projectById(launchProject)?.path)}</select></label>
2959
- <div class="k-schedule"><label><span>When</span><input name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
3165
+ <div class="k-schedule"><label><span>When</span><input class="input mono" name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
2960
3166
  <p class="au-presets">${CRON_PRESETS.map(([l, c]) => `<button type="button" class="lp-chip" data-cron="${c}">${l}</button>`).join('')}<span class="muted xs">minute hour day month weekday</span></p></div>
2961
- <div class="k-ci"><label><span>Branch</span><input name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
2962
- <div class="k-issues"><label><span>Label</span><input name="label" spellcheck="false" placeholder="wisp"></label></div>
3167
+ <div class="k-ci"><label><span>Branch</span><input class="input mono" name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
3168
+ <div class="k-issues"><label><span>Label</span><input class="input" name="label" spellcheck="false" placeholder="wisp"></label></div>
2963
3169
  <div class="k-gh"><label><span>Check every</span><select name="every">${[2, 5, 15, 60].map((m) => `<option value="${m}" ${Number(d.every || 5) === m ? 'selected' : ''}>${m} min</option>`).join('')}</select></label></div>
2964
3170
  <label><span>Run</span><select name="recipeId"><option value="">This prompt</option>${recipes.map((r) => `<option value="${r.id}" ${r.id === d.recipeId ? 'selected' : ''}>Recipe: ${esc(r.name)}</option>`).join('')}</select></label>
2965
- <label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea name="prompt" rows="6" spellcheck="false"></textarea></label>
3171
+ <label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea class="textarea mono" name="prompt" rows="6" spellcheck="false"></textarea></label>
2966
3172
  <label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
2967
3173
  <div class="au-row"><span>Harness</span></div>
2968
3174
  <p class="au-warn" hidden>${icon('warn')}<span>Issue text comes from GitHub: anyone who can open and label issues can steer this agent. Plan mode only reads; Full auto runs shell commands without asking.</span></p>
@@ -3054,7 +3260,7 @@ function runRecipe(r) {
3054
3260
  <form class="modal run-modal">
3055
3261
  <header><h2>${esc(r.name)}</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
3056
3262
  <p class="cmp-prompt">${esc(r.prompt)}</p>
3057
- ${r.params.map((p) => `<label class="ship-field"><span>${esc(p)}</span><input name="p:${esc(p)}" spellcheck="false" required></label>`).join('')}
3263
+ ${r.params.map((p) => `<label class="ship-field"><span>${esc(p)}</span><input class="input" name="p:${esc(p)}" spellcheck="false" required></label>`).join('')}
3058
3264
  ${needsProject ? `<label class="ship-field"><span>Project</span><select name="cwd">${projectOptions(projectById(launchProject)?.path)}</select></label>` : ''}
3059
3265
  <div class="ship-foot"><span class="muted xs">${r.agent ? `${esc(agentName(r.agent))}${r.model ? ` · ${esc(r.model)}` : ''}` : 'Launcher harness'} · ${esc(MODES.find((m) => m.id === r.mode)?.label || 'launcher mode')}</span><button class="primary sm">${icon('enter')}Run</button></div>
3060
3266
  </form>`);
@@ -3190,22 +3396,22 @@ async function openPhone({ host } = {}) {
3190
3396
  $('.ph-body', node).innerHTML = `
3191
3397
  <div class="settings-modal ph-sec">
3192
3398
  <div class="setting"><span>Send me</span>
3193
- <label class="ph-check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
3194
- <label class="ph-check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
3399
+ <label class="check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
3400
+ <label class="check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
3195
3401
  </div>
3196
3402
  <section class="ph-sec">
3197
3403
  <h4>ntfy <span class="muted xs">· tap a button on the notification to answer</span>${status(r.status.ntfy)}<button class="switch" data-sw="ntfy" role="switch" aria-checked="${r.ntfy.enabled}"></button></h4>
3198
3404
  <div class="settings-modal">
3199
- <label><span>Topic</span><input data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
3200
- <label><span>Server</span><input data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
3201
- <label><span>Access token</span><input data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
3405
+ <label><span>Topic</span><input class="input mono" data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
3406
+ <label><span>Server</span><input class="input mono" data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
3407
+ <label><span>Access token</span><input class="input mono" data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
3202
3408
  </div>
3203
3409
  <p class="hint">Install the ntfy app and subscribe to the topic. Anyone who knows the topic name can read it, so keep it long and random; answers are signed, so nobody else can send them.</p>
3204
3410
  </section>
3205
3411
  <section class="ph-sec">
3206
3412
  <h4>Telegram <span class="muted xs">· buttons, or reply to a message in your own words</span>${status(r.status.telegram)}<button class="switch" data-sw="telegram" role="switch" aria-checked="${tg.enabled}"></button></h4>
3207
3413
  <div class="settings-modal">
3208
- <label><span>Bot token</span><input data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
3414
+ <label><span>Bot token</span><input class="input mono" data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
3209
3415
  </div>
3210
3416
  <p class="hint">${
3211
3417
  tg.linked
@@ -3252,6 +3458,304 @@ async function openPhone({ host } = {}) {
3252
3458
  if (r) paint();
3253
3459
  }
3254
3460
 
3461
+ // --- machines: other computers, over SSH ---------------------------------------------------------
3462
+
3463
+ // A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
3464
+ // as <id>@<machine>. Projects can live on a machine; launching in one runs the agent there.
3465
+ const machineById = (id) => S.machines.find((m) => m.id === id);
3466
+ const machineOnline = (id) => machineById(id)?.state === 'online';
3467
+ const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
3468
+ const projectTitle = (p) => {
3469
+ if (!p) return '';
3470
+ const m = p.machine && machineById(p.machine);
3471
+ return m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : p.path;
3472
+ };
3473
+
3474
+ function machinePill(mt) {
3475
+ const on = machineOnline(mt.id);
3476
+ return `<span class="q-machine ${on ? '' : 'off'}" title="Runs on ${esc(mt.name)} (${esc(mt.target)})${on ? '' : ' · not connected'}">${icon('server')}<span>${esc(mt.name)}</span></span>`;
3477
+ }
3478
+
3479
+ function machinesSummary() {
3480
+ const n = S.machines.length;
3481
+ if (!n) return 'None yet · run agents on another computer over SSH';
3482
+ const on = S.machines.filter((m) => m.state === 'online').length;
3483
+ return `${n} machine${n === 1 ? '' : 's'} · ${on ? `${on} online` : 'none online'}`;
3484
+ }
3485
+
3486
+ const loadMachines = () => api('/machines').then(setMachines).catch(() => {});
3487
+ function setMachines(list) {
3488
+ const was = new Map(S.machines.map((m) => [m.id, m.state === 'online']));
3489
+ S.machines = list;
3490
+ paintMachinesBtn();
3491
+ renderLaunchChips();
3492
+ renderProjects();
3493
+ $('.machines-modal')?.refresh();
3494
+ $('.projects-modal')?.refresh?.();
3495
+ $('.menu.where-menu')?.refresh();
3496
+ $('.settings-modal .machines-hint') && ($('.settings-modal .machines-hint').textContent = machinesSummary());
3497
+ // Coming online lists its sessions; going offline greys their cards.
3498
+ if (list.some((m) => (was.get(m.id) || false) !== (m.state === 'online'))) loadSessions();
3499
+ }
3500
+
3501
+ function paintMachinesBtn() {
3502
+ const b = $('#machinesBtn');
3503
+ if (!b) return;
3504
+ const on = S.machines.filter((m) => m.state === 'online').length;
3505
+ const trouble = S.machines.some((m) => m.state === 'error' || m.state === 'reconnecting');
3506
+ b.classList.toggle('live', on > 0);
3507
+ b.classList.toggle('trouble', trouble);
3508
+ const n = $('.ab-n', b);
3509
+ n.hidden = !on;
3510
+ n.textContent = on;
3511
+ b.title = S.machines.length ? `Machines: ${machinesSummary()}` : 'Machines: run agents on another computer over SSH';
3512
+ }
3513
+
3514
+ async function connectMachine(id) {
3515
+ try {
3516
+ await api(`/machines/${id}/connect`, { method: 'POST', body: {} });
3517
+ } catch (err) {
3518
+ toast(esc(err.message), { tone: 'bad' });
3519
+ }
3520
+ }
3521
+
3522
+ function offlineToast(id) {
3523
+ const m = machineById(id);
3524
+ if (m?.state === 'connecting' || m?.state === 'reconnecting') return toast(`Still connecting to <b>${esc(m.name)}</b>…`);
3525
+ toast(`<b>${esc(m?.name || 'That machine')}</b> isn’t connected`, { tone: 'bad', action: { label: 'Connect', run: () => connectMachine(id) } });
3526
+ }
3527
+
3528
+ const STEP_ICON = { done: 'check', fail: 'x', warn: 'warn', run: '', pending: '' };
3529
+
3530
+ function openMachines({ host, focusAdd = false } = {}) {
3531
+ const node = el(`<div class="modal machines-modal">
3532
+ <header><h2>Machines</h2><button class="icon-btn close">${icon('x')}</button></header>
3533
+ <div class="mc-body">
3534
+ <p class="mem-lede">Run agents on your other computers: a dev box, a GPU server, a cloud VM. wisp signs in over SSH, starts itself there and tunnels back, so worktrees, diffs and Ship it work as usual, and agents keep going when this computer sleeps.</p>
3535
+ <div class="mc-list"></div>
3536
+ <section class="mc-add"></section>
3537
+ </div>
3538
+ </div>`);
3539
+ const close = present(node, host);
3540
+ const body = $('.mc-body', node);
3541
+ let editing = null; // machine id whose form is open, '' = a new one, null = closed
3542
+ let hosts = [];
3543
+ api('/machines/ssh-hosts')
3544
+ .then((h) => {
3545
+ hosts = h;
3546
+ const dl = $('#sshHosts', node);
3547
+ if (dl) dl.innerHTML = hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('');
3548
+ })
3549
+ .catch(() => {});
3550
+
3551
+ // Projects on a machine open here, with a way back.
3552
+ const sub = (view) => {
3553
+ const head = $(':scope > header', node);
3554
+ body.hidden = true;
3555
+ head.hidden = true;
3556
+ view.classList.add('embedded');
3557
+ const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
3558
+ back.onclick = () => {
3559
+ view.remove();
3560
+ body.hidden = false;
3561
+ head.hidden = false;
3562
+ paint();
3563
+ };
3564
+ $('header', view).prepend(back);
3565
+ $('.close', view).onclick = close;
3566
+ node.append(view);
3567
+ return close;
3568
+ };
3569
+
3570
+ const steps = (m) => `<ol class="mc-steps">${m.steps
3571
+ .map((st) => `<li class="s-${st.status}"><span class="mc-si">${STEP_ICON[st.status] ? icon(STEP_ICON[st.status]) : ''}</span><b>${esc(st.label)}</b>${st.detail ? `<small>${esc(st.detail)}</small>` : ''}</li>`)
3572
+ .join('')}</ol>`;
3573
+ const trouble = (m) => `<div class="mc-hint ${m.state === 'reconnecting' ? 'soft' : ''}">
3574
+ <p><b>${esc(m.state === 'reconnecting' ? `${m.error || 'Connection lost.'} Trying again${m.attempt > 1 ? ` (attempt ${m.attempt})` : ''}…` : m.error)}</b></p>
3575
+ ${m.hint?.text ? `<p>${esc(m.hint.text)}</p>` : ''}
3576
+ ${m.hint?.command ? `<div class="mc-cmd"><code>${esc(m.hint.command)}</code><button class="ghost xs" data-a="copy" data-cmd="${esc(m.hint.command)}" title="Copy">${icon('copy')}</button></div>` : ''}
3577
+ ${m.stale ? `<button class="ghost sm" data-a="restart">${icon('rerun')}Restart wisp there</button>` : ''}
3578
+ ${m.log.length ? `<details class="mc-log"><summary>Log</summary><pre>${esc(m.log.join('\n'))}</pre></details>` : ''}
3579
+ </div>`;
3580
+ const projects = (m) => {
3581
+ const list = S.config.projects.filter((p) => p.machine === m.id);
3582
+ return `<div class="mc-projects">${list.map((p) => `<span class="lp-chip c-${p.color}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</span>`).join('')}
3583
+ <button class="lp-chip add" data-a="projects">${icon(list.length ? 'plus' : 'folder')}${list.length ? 'Add' : 'Pick projects on it…'}</button></div>`;
3584
+ };
3585
+ const card = (m) => {
3586
+ const st = m.state;
3587
+ const busy = st === 'connecting' || st === 'reconnecting';
3588
+ const ready = (m.remote?.agents || []).filter((a) => a.available);
3589
+ return `<div class="mc st-${st}" data-id="${m.id}">
3590
+ <div class="mc-head">
3591
+ <span class="mc-icon">${icon('server')}<i class="mc-dot"></i></span>
3592
+ <div class="mc-title"><b>${esc(m.name)}</b><span>${esc(m.target)}${m.port !== 4477 ? ` · port ${m.port}` : ''}</span></div>
3593
+ <span class="mc-state">${MACHINE_STATE[st] || st}</span>
3594
+ ${st === 'online' || busy ? `<button class="ghost sm" data-a="disconnect">${busy ? 'Cancel' : 'Disconnect'}</button>` : `<button class="primary sm" data-a="connect">${st === 'error' ? 'Try again' : 'Connect'}</button>`}
3595
+ <button class="icon-btn" data-a="menu" title="More">${icon('dots')}</button>
3596
+ </div>
3597
+ ${
3598
+ st === 'online'
3599
+ ? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
3600
+ : ''
3601
+ }
3602
+ ${busy || st === 'error' ? steps(m) : ''}
3603
+ ${(st === 'error' || st === 'reconnecting') && m.error ? trouble(m) : ''}
3604
+ ${st === 'online' || m.projects ? projects(m) : ''}
3605
+ ${editing === m.id ? `<div class="mc-edit">${form(m)}</div>` : ''}
3606
+ </div>`;
3607
+ };
3608
+ const form = (m) => `<form class="mc-form" data-id="${m?.id || ''}">
3609
+ <div class="mc-row">
3610
+ <label class="mc-field grow"><span>SSH host</span><input class="input mono" name="target" list="sshHosts" placeholder="me@devbox, 10.0.0.5, or a Host from ~/.ssh/config" spellcheck="false" autocomplete="off" autocapitalize="off" value="${esc(m?.target || '')}" required></label>
3611
+ <label class="mc-field"><span>Name</span><input class="input" name="name" placeholder="${esc(m?.name || 'devbox')}" value="${esc(m?.name || '')}" spellcheck="false"></label>
3612
+ </div>
3613
+ <details class="mc-adv" ${m && (m.port !== 4477 || m.command) ? 'open' : ''}><summary>Advanced</summary>
3614
+ <div class="mc-row">
3615
+ <label class="mc-field"><span>wisp’s port there</span><input class="input mono" name="port" type="number" min="1" max="65535" value="${m?.port || 4477}"></label>
3616
+ <label class="mc-field grow"><span>Start command</span><input class="input mono" name="command" placeholder="${esc(S.machines[0]?.defaultCommand || 'npx -y wisp-agents@<this version>')}" value="${esc(m?.command || '')}" spellcheck="false"></label>
3617
+ </div>
3618
+ <label class="check mc-check"><input type="checkbox" name="autoConnect" ${m?.autoConnect === false ? '' : 'checked'}> Connect when wisp starts</label>
3619
+ </details>
3620
+ <div class="mc-foot">
3621
+ <p class="hint">${m ? 'Changing the host, port or command reconnects.' : 'Uses your SSH keys, agent and ~/.ssh/config; wisp never asks for a password. The machine needs Node.js 20+ and the harness CLIs, signed in.'}</p>
3622
+ ${editing !== null && (m || S.machines.length) ? '<button type="button" class="ghost sm" data-a="cancel">Cancel</button>' : ''}
3623
+ <button class="primary sm" type="submit">${m ? 'Save' : `${icon('server')}Connect`}</button>
3624
+ </div>
3625
+ </form>`;
3626
+
3627
+ const paint = () => {
3628
+ if (!node.isConnected && !host) return;
3629
+ $('.mc-list', node).innerHTML = S.machines.map(card).join('');
3630
+ const add = $('.mc-add', node);
3631
+ // The form for a new machine: open when there are none yet, else behind a button.
3632
+ if (!S.machines.length || editing === '') {
3633
+ if (!$('form[data-id=""]', add)) add.innerHTML = `<h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('')}</datalist>`;
3634
+ } else add.innerHTML = `<button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}"></option>`).join('')}</datalist>`;
3635
+ };
3636
+
3637
+ const menu = (m, anchor) => {
3638
+ $('.menu.machine')?.close();
3639
+ const r = anchor.getBoundingClientRect();
3640
+ const box = el(`<div class="menu machine" style="top:${r.bottom + 4}px;left:${Math.max(8, r.right - 230)}px">
3641
+ <button data-m="edit">${icon('pen')}Edit…</button>
3642
+ <button data-m="projects">${icon('folder')}Projects on it…</button>
3643
+ ${m.state === 'online' ? `<button data-m="restart">${icon('rerun')}Restart wisp there</button><button data-m="stop" class="danger">${icon('stop')}Stop wisp there</button>` : ''}
3644
+ <button data-m="remove" class="danger">${icon('trash')}Remove machine</button>
3645
+ </div>`);
3646
+ const shut = () => (box.remove(), document.removeEventListener('mousedown', outside));
3647
+ const outside = (ev) => {
3648
+ if (!box.contains(ev.target)) shut();
3649
+ };
3650
+ box.close = shut;
3651
+ setTimeout(() => document.addEventListener('mousedown', outside));
3652
+ box.onclick = (ev) => {
3653
+ const a = ev.target.closest('button')?.dataset.m;
3654
+ shut();
3655
+ if (a) act(a, m);
3656
+ };
3657
+ document.body.append(box);
3658
+ };
3659
+
3660
+ const act = async (a, m) => {
3661
+ try {
3662
+ if (a === 'connect') await api(`/machines/${m.id}/connect`, { method: 'POST', body: {} });
3663
+ if (a === 'disconnect') await api(`/machines/${m.id}/disconnect`, { method: 'POST', body: {} });
3664
+ if (a === 'projects') openProjects({ machine: m.id, host: sub, focusAdd: true });
3665
+ if (a === 'edit') {
3666
+ editing = m.id;
3667
+ paint();
3668
+ $(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
3669
+ }
3670
+ if (a === 'restart') {
3671
+ if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. Restarting wisp there stops ${m.working === 1 ? 'it' : 'them'}. Restart?`)) return;
3672
+ await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
3673
+ }
3674
+ if (a === 'stop') {
3675
+ if (!confirm(`Stop wisp on ${m.name}? ${m.working ? `${m.working} working agent${m.working === 1 ? '' : 's'} stop too. ` : ''}Its sessions stay there for next time.`)) return;
3676
+ await api(`/machines/${m.id}/stop`, { method: 'POST', body: {} });
3677
+ toast(`Stopped wisp on <b>${esc(m.name)}</b>`);
3678
+ }
3679
+ if (a === 'remove') {
3680
+ if (!confirm(`Remove ${m.name} from wisp? Its projects leave the launcher. Nothing on the machine is touched, and wisp keeps running there until you stop it.`)) return;
3681
+ await api(`/machines/${m.id}`, { method: 'DELETE' });
3682
+ await loadConfig();
3683
+ await loadMachines();
3684
+ }
3685
+ } catch (err) {
3686
+ toast(esc(err.message), { tone: 'bad' });
3687
+ }
3688
+ };
3689
+
3690
+ node.addEventListener('click', async (e) => {
3691
+ const b = e.target.closest('[data-a]');
3692
+ if (!b || !body.contains(b)) return;
3693
+ const a = b.dataset.a;
3694
+ const m = machineById(b.closest('.mc')?.dataset.id);
3695
+ if (a === 'copy') {
3696
+ copy(b.dataset.cmd);
3697
+ toast('Copied. Run it in a terminal, then connect again.');
3698
+ return;
3699
+ }
3700
+ if (a === 'new') {
3701
+ editing = '';
3702
+ paint();
3703
+ $('.mc-add input[name="target"]', node)?.focus();
3704
+ return;
3705
+ }
3706
+ if (a === 'cancel') {
3707
+ editing = null;
3708
+ paint();
3709
+ return;
3710
+ }
3711
+ if (a === 'menu' && m) {
3712
+ menu(m, b);
3713
+ return;
3714
+ }
3715
+ if (m) act(a, m);
3716
+ });
3717
+ // The name follows the host until you type one.
3718
+ node.addEventListener('input', (e) => {
3719
+ if (e.target.name !== 'target') return;
3720
+ const name = $('input[name="name"]', e.target.form);
3721
+ const h = hosts.find((x) => x.host === e.target.value.trim());
3722
+ name.placeholder = (h?.host || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
3723
+ });
3724
+ node.addEventListener('submit', async (e) => {
3725
+ e.preventDefault();
3726
+ const f = e.target;
3727
+ const id = f.dataset.id;
3728
+ const v = Object.fromEntries(new FormData(f));
3729
+ const payload = { target: v.target.trim(), name: v.name.trim(), port: Number(v.port) || 4477, command: v.command.trim(), autoConnect: !!v.autoConnect };
3730
+ const btn = $('button[type="submit"]', f);
3731
+ btn.disabled = true;
3732
+ try {
3733
+ if (id) await api(`/machines/${id}`, { method: 'PATCH', body: payload });
3734
+ else await api('/machines', { method: 'POST', body: payload });
3735
+ editing = null;
3736
+ await loadMachines();
3737
+ paint();
3738
+ } catch (err) {
3739
+ toast(esc(err.message), { tone: 'bad' });
3740
+ } finally {
3741
+ btn.disabled = false;
3742
+ }
3743
+ });
3744
+ node.refresh = () => {
3745
+ // Don't clobber a half-typed form: repaint the cards only.
3746
+ const typing = node.contains(document.activeElement) && document.activeElement.matches('input, select');
3747
+ if (typing && editing !== null && editing !== '') return;
3748
+ if (typing) $('.mc-list', node).innerHTML = S.machines.map(card).join('');
3749
+ else paint();
3750
+ };
3751
+ paint();
3752
+ if (focusAdd && S.machines.length) {
3753
+ editing = '';
3754
+ paint();
3755
+ }
3756
+ if (focusAdd || !S.machines.length) setTimeout(() => $('.mc-add input[name="target"]', node)?.focus(), 60);
3757
+ }
3758
+
3255
3759
  // --- settings -----------------------------------------------------------------------------
3256
3760
 
3257
3761
  // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
@@ -3260,6 +3764,7 @@ const SETTINGS_PANES = [
3260
3764
  { id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
3261
3765
  { id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
3262
3766
  { id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
3767
+ { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
3263
3768
  { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
3264
3769
  { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
3265
3770
  { id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
@@ -3288,7 +3793,7 @@ function openSettings(opts) {
3288
3793
  .join('')}</select>`)}
3289
3794
  ${row('Take over in', 'The terminal a session opens in when you take it over', `<select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select>`)}
3290
3795
  <div class="sx-row custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><div class="sx-text"><b>Terminal command</b><small><code>{cwd}</code> and <code>{cmd}</code> are filled in</small></div>
3291
- <div class="sx-ctl"><input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
3796
+ <div class="sx-ctl"><input class="input mono" name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
3292
3797
  </div>
3293
3798
  <div class="sx-group">
3294
3799
  ${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
@@ -3306,7 +3811,12 @@ function openSettings(opts) {
3306
3811
  <div class="sx-group">${S.agents
3307
3812
  .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>`))
3308
3813
  .join('')}</div>
3309
- <p class="hint">Others plug in via <code>server/adapters</code>.</p>
3814
+ </section>
3815
+ <section class="sx-pane" data-pane="machines">
3816
+ <div class="sx-group">
3817
+ ${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
3818
+ </div>
3819
+ <p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
3310
3820
  </section>
3311
3821
  <section class="sx-pane" data-pane="memory">
3312
3822
  <div class="sx-group">
@@ -3462,6 +3972,7 @@ function openSettings(opts) {
3462
3972
  <div><span>Questions</span><b>${x.questions}</b></div>`;
3463
3973
  })
3464
3974
  .catch(() => ($('.sx-usage', node).innerHTML = ''));
3975
+ $('.machines-hint', node).textContent = machinesSummary();
3465
3976
  api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
3466
3977
  paintLearn();
3467
3978
  };
@@ -3470,6 +3981,7 @@ function openSettings(opts) {
3470
3981
  $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
3471
3982
  $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
3472
3983
  $('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
3984
+ $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
3473
3985
  }
3474
3986
 
3475
3987
  // --- memory: what the background retros learned ---------------------------------------------
@@ -3728,6 +4240,8 @@ function paletteItems() {
3728
4240
  { group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
3729
4241
  { group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
3730
4242
  { group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
4243
+ { group: a, label: 'Machines', icon: 'server', keywords: 'remote ssh server computer devbox cloud vm', run: () => openMachines() },
4244
+ { group: a, label: 'Add a machine…', icon: 'server', keywords: 'remote ssh server computer connect', run: () => openMachines({ focusAdd: true }) },
3731
4245
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
3732
4246
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
3733
4247
  );
@@ -3772,7 +4286,14 @@ function askNotify() {
3772
4286
  function notifyFinished(s) {
3773
4287
  const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
3774
4288
  if (viewing) return;
3775
- const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
4289
+ let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
4290
+ // A task run in several places (or compared) says so once, when its last run finishes.
4291
+ const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
4292
+ if (runs.length > 1 && s.state !== 'waiting') {
4293
+ if (runs.some((x) => x.id !== s.id && x.state === 'running')) return;
4294
+ const places = new Set(runs.map((x) => x.machine?.id || '')).size;
4295
+ verb = places > 1 ? `is ready on ${places} machines` : `is ready (${runs.length} runs)`;
4296
+ }
3776
4297
  toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
3777
4298
  if (notifyOn() && !document.hasFocus()) {
3778
4299
  const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/favicon.svg', tag: s.id });
@@ -3818,12 +4339,13 @@ function connect() {
3818
4339
  });
3819
4340
  es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
3820
4341
  es.addEventListener('remote', () => $('.phone-modal')?.refresh());
4342
+ es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
3821
4343
  es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
3822
4344
  es.addEventListener('preferences', () => {
3823
4345
  $('.memory-modal')?.refresh();
3824
4346
  $('.settings-modal')?.paintLearn?.();
3825
4347
  });
3826
- es.addEventListener('hello', () => (loadSessions(), loadUpdate())); // also after sleep/reconnect
4348
+ es.addEventListener('hello', () => (loadSessions(), loadUpdate(), loadMachines())); // also after sleep/reconnect
3827
4349
  }
3828
4350
 
3829
4351
  async function loadSessions() {
@@ -3981,6 +4503,7 @@ document.addEventListener('keydown', (e) => {
3981
4503
 
3982
4504
  $('#settingsBtn').onclick = openSettings;
3983
4505
  $('#searchBtn').onclick = openPalette;
4506
+ $('#machinesBtn').onclick = () => openMachines();
3984
4507
  $('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
3985
4508
  $('#updateBtn').onclick = installUpdate;
3986
4509
  $('#searchBtn').title = `Search (${combo('K')})`;
@@ -4046,7 +4569,7 @@ window.addEventListener('focus', () => {
4046
4569
  });
4047
4570
 
4048
4571
  (async () => {
4049
- [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
4572
+ [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
4050
4573
  renderLauncher();
4051
4574
  connect();
4052
4575
  route();