wisp-agents 0.8.0 → 0.10.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
@@ -1491,7 +1633,6 @@ function renderBody(id, { fresh }) {
1491
1633
  ${s.origin ? `<span class="m" title="Launched by a ${esc(s.origin.kind)}">${icon(s.origin.kind === 'trigger' ? 'replay' : 'spark')}${esc(s.origin.name)}</span>` : ''}`;
1492
1634
  const live = s.state === 'running' || s.state === 'waiting';
1493
1635
  // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
1494
- $$('[data-a="ship"]', body).forEach((b) => (b.hidden = !s.worktree || live || s.agent === 'wisp' || s.pr?.state === 'MERGED'));
1495
1636
  renderPrStrip(id);
1496
1637
  $$('[data-a="done"]', body).forEach((b) => {
1497
1638
  b.classList.toggle('on', s.state === 'done');
@@ -1690,7 +1831,7 @@ function renderAskCard(s) {
1690
1831
  <p class="q-text">${esc(a.question)}</p>
1691
1832
  ${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
1692
1833
  ${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>
1834
+ <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
1835
  </div>`);
1695
1836
  const answer = async (text) => {
1696
1837
  if (!text.trim()) return;
@@ -2068,7 +2209,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
2068
2209
  .join('')}
2069
2210
  </div>
2070
2211
  <div class="pk-main">
2071
- <label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
2212
+ <label class="field pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
2072
2213
  <div class="pk-list" role="listbox" aria-label="Model"></div>
2073
2214
  <div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
2074
2215
  </div>
@@ -2296,7 +2437,7 @@ function taskMenu(id, e) {
2296
2437
  ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
2297
2438
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
2298
2439
  <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
2299
- ${s.branch && !busy && s.pr?.state !== 'MERGED' ? `<button data-a="ship">${icon('send')}${s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…'}${keyHint('S')}</button>` : ''}
2440
+ ${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
2300
2441
  ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
2301
2442
  ${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
2302
2443
  <button data-a="recipe">${icon('spark')}Save as recipe…</button>
@@ -2456,9 +2597,20 @@ const EDITORS = {
2456
2597
  idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
2457
2598
  };
2458
2599
 
2600
+ // VS Code and its forks open a file on a machine through their Remote - SSH extension.
2601
+ const REMOTE_EDITORS = new Set(['vscode', 'cursor', 'windsurf']);
2602
+
2459
2603
  function openInEditor(s, p, line) {
2460
2604
  const editor = S.config.defaults.editor || 'vscode';
2461
2605
  const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
2606
+ if (s.machine) {
2607
+ if (!REMOTE_EDITORS.has(editor)) {
2608
+ copy(abs);
2609
+ return toast(`Copied path ${esc(abs)} (on ${esc(s.machine.name)})`);
2610
+ }
2611
+ location.href = `${editor}://vscode-remote/ssh-remote+${encodeURIComponent(s.machine.target)}${abs}${line ? `:${line}` : ''}`;
2612
+ return;
2613
+ }
2462
2614
  if (editor === 'none' || !EDITORS[editor]) {
2463
2615
  copy(abs);
2464
2616
  return toast(`Copied path ${esc(abs)}`);
@@ -2490,12 +2642,14 @@ function reveal(id) {
2490
2642
 
2491
2643
  const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
2492
2644
 
2493
- async function openProjects({ focusAdd = false } = {}) {
2645
+ // where: a machine id to add folders on that computer (server/machines.js), else this one.
2646
+ async function openProjects({ focusAdd = false, machine = null, host } = {}) {
2494
2647
  const node = el(`
2495
2648
  <div class="modal projects-modal">
2496
2649
  <header><h2>Projects</h2><button class="icon-btn close">${icon('x')}</button></header>
2650
+ <div class="mem-tabs where"></div>
2497
2651
  <div class="add-row">
2498
- <div class="path-input">
2652
+ <div class="field lg mono path-input">
2499
2653
  ${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
2500
2654
  <input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
2501
2655
  <div class="suggest" hidden></div>
@@ -2503,23 +2657,55 @@ async function openProjects({ focusAdd = false } = {}) {
2503
2657
  ${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
2504
2658
  <button class="primary" id="padd">Add</button>
2505
2659
  </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>
2660
+ <p class="hint"></p>
2507
2661
  <div class="plist" id="plist"></div>
2508
2662
  <div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
2509
2663
  </div>`);
2510
- const close = overlay(node);
2511
- $('.close', node).onclick = close;
2664
+ present(node, host);
2512
2665
  const input = $('#ppath', node);
2513
2666
  const sug = $('.suggest', node);
2667
+ let where = machine && machineById(machine) ? machine : null;
2668
+ const here = () => machineById(where);
2669
+ const home = () => (where ? here()?.remote?.home : S.config.home) || '';
2670
+ const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
2671
+
2672
+ const paintWhere = () => {
2673
+ const tabs = $('.where', node);
2674
+ tabs.innerHTML = `<button data-w="" class="${where ? '' : 'on'}">${icon('folder')}This computer</button>${S.machines
2675
+ .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>`)
2676
+ .join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
2677
+ const m = here();
2678
+ input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
2679
+ $$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
2680
+ $('.hint', node).innerHTML = m
2681
+ ? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
2682
+ : 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
2683
+ };
2684
+ $('.where', node).addEventListener('click', (e) => {
2685
+ const b = e.target.closest('[data-w]');
2686
+ if (!b) return;
2687
+ if (b.dataset.w === '+') {
2688
+ openMachines({ focusAdd: true });
2689
+ return;
2690
+ }
2691
+ where = b.dataset.w || null;
2692
+ sug.hidden = true;
2693
+ paintWhere();
2694
+ list();
2695
+ discover();
2696
+ input.focus();
2697
+ });
2514
2698
 
2515
2699
  const add = async (p) => {
2516
2700
  try {
2517
- const proj = await api('/projects', { method: 'POST', body: { path: p } });
2701
+ const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
2518
2702
  await loadConfig();
2519
- launchProject = proj.id;
2520
- save('wisp.launchProject', launchProject);
2703
+ launchPicks[placeOf(proj)] = proj.id;
2704
+ if (!launchWhere.includes(placeOf(proj))) launchWhere = [placeOf(proj)];
2705
+ if (placeOf(proj) === launchWhere[0]) launchProject = proj.id;
2706
+ saveWhere();
2521
2707
  renderLaunchChips();
2522
- toast(`Added <b>${esc(proj.name)}</b>`);
2708
+ toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
2523
2709
  input.value = '';
2524
2710
  sug.hidden = true;
2525
2711
  list();
@@ -2528,7 +2714,9 @@ async function openProjects({ focusAdd = false } = {}) {
2528
2714
  toast(esc(err.message), { tone: 'bad' });
2529
2715
  }
2530
2716
  };
2531
- $('#padd', node).onclick = () => input.value.trim() && add(input.value.trim());
2717
+ $('#padd', node).onclick = () => {
2718
+ if (input.value.trim()) add(input.value.trim());
2719
+ };
2532
2720
  const browse = async () => {
2533
2721
  const dir = await window.wispDesktop.pickFolder();
2534
2722
  if (dir) add(dir);
@@ -2542,10 +2730,10 @@ async function openProjects({ focusAdd = false } = {}) {
2542
2730
  t = setTimeout(async () => {
2543
2731
  const q = input.value;
2544
2732
  if (!q) return (sug.hidden = true);
2545
- const dirs = await api(`/fs?q=${encodeURIComponent(q)}`).catch(() => []);
2733
+ const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
2546
2734
  sel = -1;
2547
2735
  sug.hidden = !dirs.length;
2548
- sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(d.replace(S.config.home, '~'))}</button>`).join('');
2736
+ sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(tilde(d))}</button>`).join('');
2549
2737
  }, 120);
2550
2738
  });
2551
2739
  input.addEventListener('keydown', (e) => {
@@ -2556,7 +2744,7 @@ async function openProjects({ focusAdd = false } = {}) {
2556
2744
  opts.forEach((o, i) => o.classList.toggle('on', i === sel));
2557
2745
  } else if (e.key === 'Tab' && opts.length) {
2558
2746
  e.preventDefault();
2559
- input.value = (opts[Math.max(0, sel)].dataset.p + '/').replace(S.config.home, '~');
2747
+ input.value = tilde(opts[Math.max(0, sel)].dataset.p + '/');
2560
2748
  input.dispatchEvent(new Event('input'));
2561
2749
  } else if (e.key === 'Enter') {
2562
2750
  e.preventDefault();
@@ -2570,20 +2758,20 @@ async function openProjects({ focusAdd = false } = {}) {
2570
2758
 
2571
2759
  const list = () => {
2572
2760
  const box = $('#plist', node);
2573
- const projects = S.config.projects;
2761
+ const projects = S.config.projects.filter((p) => (p.machine || null) === where);
2574
2762
  box.innerHTML = projects.length
2575
2763
  ? projects
2576
2764
  .map((p) => {
2577
2765
  const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
2578
2766
  return `<div class="prow c-${p.color}" data-id="${p.id}">
2579
2767
  <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>
2768
+ <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
2581
2769
  <span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
2582
2770
  <button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
2583
2771
  </div>`;
2584
2772
  })
2585
2773
  .join('')
2586
- : '<p class="muted">No projects yet. Add a folder above, or pick one below.</p>';
2774
+ : `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
2587
2775
  $$('.prow', box).forEach((row) => {
2588
2776
  const id = row.dataset.id;
2589
2777
  const p = projectById(id);
@@ -2603,18 +2791,35 @@ async function openProjects({ focusAdd = false } = {}) {
2603
2791
 
2604
2792
  const discover = async () => {
2605
2793
  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
- : '';
2794
+ const m = here();
2795
+ const at = where;
2612
2796
  box.onclick = (e) => {
2613
2797
  const b = e.target.closest('.found-item');
2614
2798
  if (b) add(b.dataset.p);
2799
+ if (e.target.closest('[data-a="connect"]')) connectMachine(at);
2615
2800
  };
2801
+ if (m && m.state !== 'online') {
2802
+ 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>`;
2803
+ return;
2804
+ }
2805
+ box.innerHTML = `<h4>Found on ${m ? esc(m.name) : 'this machine'}</h4><p class="muted">Looking for git repos…</p>`;
2806
+ const found = await api(m ? `/machines/${m.id}/discover` : '/projects/discover').catch(() => []);
2807
+ if (where !== at) return; // switched tabs meanwhile
2808
+ box.innerHTML = found.length
2809
+ ? `<h4>Found on ${m ? esc(m.name) : 'this machine'} <span class="muted">· git repos</span></h4><div class="found">${found
2810
+ .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>`)
2811
+ .join('')}</div>`
2812
+ : '';
2813
+ };
2814
+ let wasOnline = here()?.state === 'online';
2815
+ node.refresh = () => {
2816
+ paintWhere();
2817
+ const on = here()?.state === 'online';
2818
+ if (on !== wasOnline) discover();
2819
+ wasOnline = on;
2616
2820
  };
2617
2821
 
2822
+ paintWhere();
2618
2823
  list();
2619
2824
  discover();
2620
2825
  if (focusAdd) setTimeout(() => input.focus(), 60);
@@ -2631,6 +2836,15 @@ function prLine(pr) {
2631
2836
  const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
2632
2837
  return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
2633
2838
  }
2839
+ // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
2840
+ // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
2841
+ function shippable(s) {
2842
+ if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
2843
+ const t = s.toShip;
2844
+ return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
2845
+ }
2846
+ const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
2847
+
2634
2848
  const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
2635
2849
 
2636
2850
  // The PR row under a card's bar; reads the live summary, so polled check results show up.
@@ -2640,7 +2854,10 @@ function renderPrStrip(id) {
2640
2854
  const s = S.sessions.get(id);
2641
2855
  if (!node || !s) return;
2642
2856
  const pr = s.pr;
2643
- $$('[data-a="ship"] span', n.body).forEach((b) => (b.textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it'));
2857
+ $$('[data-a="ship"]', n.body).forEach((b) => {
2858
+ b.hidden = !shippable(s);
2859
+ $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
2860
+ });
2644
2861
  node.hidden = !pr;
2645
2862
  if (!pr) return;
2646
2863
  const tone = prTone(pr);
@@ -2677,9 +2894,9 @@ async function shipDialog(id) {
2677
2894
  <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
2678
2895
  <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
2679
2896
  <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>`}
2897
+ ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
2898
+ ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
2899
+ <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
2683
2900
  ${!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
2901
  <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
2902
  </div>`);
@@ -2917,8 +3134,8 @@ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
2917
3134
 
2918
3135
  const recipeForm = (d) => {
2919
3136
  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>
3137
+ <label><span>Name</span><input class="input" name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
3138
+ <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
3139
  <p class="au-params muted xs"></p>
2923
3140
  <label><span>Project</span><select name="cwd">${projectOptions(d.cwd, { any: 'Any project (picked when run)' })}</select></label>
2924
3141
  <label><span>Mode</span><select name="mode">${modeOptions(d.mode, { inherit: 'Launcher default' })}</select></label>
@@ -2954,15 +3171,15 @@ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
2954
3171
  d.kind ||= 'schedule';
2955
3172
  const f = el(`<form class="au-form">
2956
3173
  <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>
3174
+ <label><span>Name</span><input class="input" name="name" maxlength="60" spellcheck="false"></label>
2958
3175
  <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>
3176
+ <div class="k-schedule"><label><span>When</span><input class="input mono" name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
2960
3177
  <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>
3178
+ <div class="k-ci"><label><span>Branch</span><input class="input mono" name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
3179
+ <div class="k-issues"><label><span>Label</span><input class="input" name="label" spellcheck="false" placeholder="wisp"></label></div>
2963
3180
  <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
3181
  <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>
3182
+ <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
3183
  <label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
2967
3184
  <div class="au-row"><span>Harness</span></div>
2968
3185
  <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 +3271,7 @@ function runRecipe(r) {
3054
3271
  <form class="modal run-modal">
3055
3272
  <header><h2>${esc(r.name)}</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
3056
3273
  <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('')}
3274
+ ${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
3275
  ${needsProject ? `<label class="ship-field"><span>Project</span><select name="cwd">${projectOptions(projectById(launchProject)?.path)}</select></label>` : ''}
3059
3276
  <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
3277
  </form>`);
@@ -3190,22 +3407,22 @@ async function openPhone({ host } = {}) {
3190
3407
  $('.ph-body', node).innerHTML = `
3191
3408
  <div class="settings-modal ph-sec">
3192
3409
  <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>
3410
+ <label class="check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
3411
+ <label class="check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
3195
3412
  </div>
3196
3413
  <section class="ph-sec">
3197
3414
  <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
3415
  <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>
3416
+ <label><span>Topic</span><input class="input mono" data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
3417
+ <label><span>Server</span><input class="input mono" data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
3418
+ <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
3419
  </div>
3203
3420
  <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
3421
  </section>
3205
3422
  <section class="ph-sec">
3206
3423
  <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
3424
  <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>
3425
+ <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
3426
  </div>
3210
3427
  <p class="hint">${
3211
3428
  tg.linked
@@ -3252,6 +3469,333 @@ async function openPhone({ host } = {}) {
3252
3469
  if (r) paint();
3253
3470
  }
3254
3471
 
3472
+ // --- machines: other computers, over SSH ---------------------------------------------------------
3473
+
3474
+ // A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
3475
+ // as <id>@<machine>. Projects can live on a machine; launching in one runs the agent there.
3476
+ const machineById = (id) => S.machines.find((m) => m.id === id);
3477
+ const machineOnline = (id) => machineById(id)?.state === 'online';
3478
+ const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
3479
+ const projectTitle = (p) => {
3480
+ if (!p) return '';
3481
+ const m = p.machine && machineById(p.machine);
3482
+ return m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : p.path;
3483
+ };
3484
+
3485
+ function machinePill(mt) {
3486
+ const on = machineOnline(mt.id);
3487
+ 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>`;
3488
+ }
3489
+
3490
+ function machinesSummary() {
3491
+ const n = S.machines.length;
3492
+ if (!n) return 'None yet · run agents on another computer over SSH';
3493
+ const on = S.machines.filter((m) => m.state === 'online').length;
3494
+ return `${n} machine${n === 1 ? '' : 's'} · ${on ? `${on} online` : 'none online'}`;
3495
+ }
3496
+
3497
+ const loadMachines = () => api('/machines').then(setMachines).catch(() => {});
3498
+ function setMachines(list) {
3499
+ const was = new Map(S.machines.map((m) => [m.id, m.state === 'online']));
3500
+ S.machines = list;
3501
+ paintMachinesBtn();
3502
+ renderLaunchChips();
3503
+ renderProjects();
3504
+ $('.machines-modal')?.refresh();
3505
+ $('.projects-modal')?.refresh?.();
3506
+ $('.menu.where-menu')?.refresh();
3507
+ $('.settings-modal .machines-hint') && ($('.settings-modal .machines-hint').textContent = machinesSummary());
3508
+ // Coming online lists its sessions; going offline greys their cards.
3509
+ if (list.some((m) => (was.get(m.id) || false) !== (m.state === 'online'))) loadSessions();
3510
+ }
3511
+
3512
+ function paintMachinesBtn() {
3513
+ const b = $('#machinesBtn');
3514
+ if (!b) return;
3515
+ const on = S.machines.filter((m) => m.state === 'online').length;
3516
+ const trouble = S.machines.some((m) => m.state === 'error' || m.state === 'reconnecting');
3517
+ b.classList.toggle('live', on > 0);
3518
+ b.classList.toggle('trouble', trouble);
3519
+ const n = $('.ab-n', b);
3520
+ n.hidden = !on;
3521
+ n.textContent = on;
3522
+ b.title = S.machines.length ? `Machines: ${machinesSummary()}` : 'Machines: run agents on another computer over SSH';
3523
+ }
3524
+
3525
+ async function connectMachine(id) {
3526
+ try {
3527
+ await api(`/machines/${id}/connect`, { method: 'POST', body: {} });
3528
+ } catch (err) {
3529
+ toast(esc(err.message), { tone: 'bad' });
3530
+ }
3531
+ }
3532
+
3533
+ function offlineToast(id) {
3534
+ const m = machineById(id);
3535
+ if (m?.state === 'connecting' || m?.state === 'reconnecting') return toast(`Still connecting to <b>${esc(m.name)}</b>…`);
3536
+ toast(`<b>${esc(m?.name || 'That machine')}</b> isn’t connected`, { tone: 'bad', action: { label: 'Connect', run: () => connectMachine(id) } });
3537
+ }
3538
+
3539
+ const STEP_ICON = { done: 'check', fail: 'x', warn: 'warn', run: '', pending: '' };
3540
+
3541
+ function openMachines({ host, focusAdd = false } = {}) {
3542
+ const node = el(`<div class="modal machines-modal">
3543
+ <header><h2>Machines</h2><button class="icon-btn close">${icon('x')}</button></header>
3544
+ <div class="mc-body">
3545
+ <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>
3546
+ <div class="mc-list"></div>
3547
+ <section class="mc-add"></section>
3548
+ </div>
3549
+ </div>`);
3550
+ const close = present(node, host);
3551
+ const body = $('.mc-body', node);
3552
+ let editing = null; // machine id whose form is open, '' = a new one, null = closed
3553
+ let hosts = []; // found on this computer: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts
3554
+ api('/machines/discover')
3555
+ .then((h) => {
3556
+ hosts = h;
3557
+ const slot = $('.mc-found-slot', node);
3558
+ if (slot) slot.innerHTML = found();
3559
+ const dl = $('#sshHosts', node);
3560
+ if (dl) dl.innerHTML = datalist();
3561
+ })
3562
+ .catch(() => {});
3563
+ const datalist = () => hosts.map((x) => `<option value="${esc(x.target)}">${esc(x.detail || '')}</option>`).join('');
3564
+ const SOURCE = { 'ssh-config': '~/.ssh/config', tailscale: 'Tailscale', 'known-hosts': 'You’ve SSHed here before' };
3565
+ // Hosts not added yet, one click from a filled-in form.
3566
+ const found = () => {
3567
+ const added = new Set(S.machines.map((m) => m.target));
3568
+ const list = hosts.filter((x) => !added.has(x.target));
3569
+ if (!list.length) return '';
3570
+ return `<div class="mc-found"><h4>Found on this computer</h4><div class="mc-found-list">${list
3571
+ .slice(0, 12)
3572
+ .map(
3573
+ (x) =>
3574
+ `<button type="button" class="lp-chip${x.online === false ? ' off' : ''}" data-a="pick" data-target="${esc(x.target)}" data-name="${esc(x.name)}" title="${esc([SOURCE[x.source], x.target, x.detail].filter(Boolean).join(' · '))}">${icon('server')}${esc(x.name)}${x.online !== undefined ? `<i class="mc-fdot"></i>` : ''}</button>`,
3575
+ )
3576
+ .join('')}</div></div>`;
3577
+ };
3578
+
3579
+ // Projects on a machine open here, with a way back.
3580
+ const sub = (view) => {
3581
+ const head = $(':scope > header', node);
3582
+ body.hidden = true;
3583
+ head.hidden = true;
3584
+ view.classList.add('embedded');
3585
+ const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
3586
+ back.onclick = () => {
3587
+ view.remove();
3588
+ body.hidden = false;
3589
+ head.hidden = false;
3590
+ paint();
3591
+ };
3592
+ $('header', view).prepend(back);
3593
+ $('.close', view).onclick = close;
3594
+ node.append(view);
3595
+ return close;
3596
+ };
3597
+
3598
+ const steps = (m) => `<ol class="mc-steps">${m.steps
3599
+ .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>`)
3600
+ .join('')}</ol>`;
3601
+ const trouble = (m) => `<div class="mc-hint ${m.state === 'reconnecting' ? 'soft' : ''}">
3602
+ <p><b>${esc(m.state === 'reconnecting' ? `${m.error || 'Connection lost.'} Trying again${m.attempt > 1 ? ` (attempt ${m.attempt})` : ''}…` : m.error)}</b></p>
3603
+ ${m.hint?.text ? `<p>${esc(m.hint.text)}</p>` : ''}
3604
+ ${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>` : ''}
3605
+ ${m.stale ? `<button class="ghost sm" data-a="restart">${icon('rerun')}Restart wisp there</button>` : ''}
3606
+ ${m.log.length ? `<details class="mc-log"><summary>Log</summary><pre>${esc(m.log.join('\n'))}</pre></details>` : ''}
3607
+ </div>`;
3608
+ const projects = (m) => {
3609
+ const list = S.config.projects.filter((p) => p.machine === m.id);
3610
+ 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('')}
3611
+ <button class="lp-chip add" data-a="projects">${icon(list.length ? 'plus' : 'folder')}${list.length ? 'Add' : 'Pick projects on it…'}</button></div>`;
3612
+ };
3613
+ const card = (m) => {
3614
+ const st = m.state;
3615
+ const busy = st === 'connecting' || st === 'reconnecting';
3616
+ const ready = (m.remote?.agents || []).filter((a) => a.available);
3617
+ return `<div class="mc st-${st}" data-id="${m.id}">
3618
+ <div class="mc-head">
3619
+ <span class="mc-icon">${icon('server')}<i class="mc-dot"></i></span>
3620
+ <div class="mc-title"><b>${esc(m.name)}</b><span>${esc(m.target)}${m.port !== 4477 ? ` · port ${m.port}` : ''}</span></div>
3621
+ <span class="mc-state">${MACHINE_STATE[st] || st}</span>
3622
+ ${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>`}
3623
+ <button class="icon-btn" data-a="menu" title="More">${icon('dots')}</button>
3624
+ </div>
3625
+ ${
3626
+ st === 'online'
3627
+ ? `<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>`
3628
+ : ''
3629
+ }
3630
+ ${busy || st === 'error' ? steps(m) : ''}
3631
+ ${(st === 'error' || st === 'reconnecting') && m.error ? trouble(m) : ''}
3632
+ ${st === 'online' || m.projects ? projects(m) : ''}
3633
+ ${editing === m.id ? `<div class="mc-edit">${form(m)}</div>` : ''}
3634
+ </div>`;
3635
+ };
3636
+ const form = (m) => `<form class="mc-form" data-id="${m?.id || ''}">
3637
+ <div class="mc-row">
3638
+ <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>
3639
+ <label class="mc-field"><span>Name</span><input class="input" name="name" placeholder="${esc(m?.name || 'devbox')}" value="${esc(m?.name || '')}" spellcheck="false"></label>
3640
+ </div>
3641
+ <details class="mc-adv" ${m && (m.port !== 4477 || m.command) ? 'open' : ''}><summary>Advanced</summary>
3642
+ <div class="mc-row">
3643
+ <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>
3644
+ <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>
3645
+ </div>
3646
+ <label class="check mc-check"><input type="checkbox" name="autoConnect" ${m?.autoConnect === false ? '' : 'checked'}> Connect when wisp starts</label>
3647
+ </details>
3648
+ <div class="mc-foot">
3649
+ <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>
3650
+ ${editing !== null && (m || S.machines.length) ? '<button type="button" class="ghost sm" data-a="cancel">Cancel</button>' : ''}
3651
+ <button class="primary sm" type="submit">${m ? 'Save' : `${icon('server')}Connect`}</button>
3652
+ </div>
3653
+ </form>`;
3654
+
3655
+ const paint = () => {
3656
+ if (!node.isConnected && !host) return;
3657
+ $('.mc-list', node).innerHTML = S.machines.map(card).join('');
3658
+ const add = $('.mc-add', node);
3659
+ // The form for a new machine: open when there are none yet, else behind a button.
3660
+ if (!S.machines.length || editing === '') {
3661
+ // Keep a half-filled form; only the suggestions above it change.
3662
+ if ($('form[data-id=""]', add)) $('.mc-found-slot', add).innerHTML = found();
3663
+ else add.innerHTML = `<div class="mc-found-slot">${found()}</div><h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${datalist()}</datalist>`;
3664
+ } else add.innerHTML = `<div class="mc-found-slot">${found()}</div><button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${datalist()}</datalist>`;
3665
+ };
3666
+
3667
+ const menu = (m, anchor) => {
3668
+ $('.menu.machine')?.close();
3669
+ const r = anchor.getBoundingClientRect();
3670
+ const box = el(`<div class="menu machine" style="top:${r.bottom + 4}px;left:${Math.max(8, r.right - 230)}px">
3671
+ <button data-m="edit">${icon('pen')}Edit…</button>
3672
+ <button data-m="projects">${icon('folder')}Projects on it…</button>
3673
+ ${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>` : ''}
3674
+ <button data-m="remove" class="danger">${icon('trash')}Remove machine</button>
3675
+ </div>`);
3676
+ const shut = () => (box.remove(), document.removeEventListener('mousedown', outside));
3677
+ const outside = (ev) => {
3678
+ if (!box.contains(ev.target)) shut();
3679
+ };
3680
+ box.close = shut;
3681
+ setTimeout(() => document.addEventListener('mousedown', outside));
3682
+ box.onclick = (ev) => {
3683
+ const a = ev.target.closest('button')?.dataset.m;
3684
+ shut();
3685
+ if (a) act(a, m);
3686
+ };
3687
+ document.body.append(box);
3688
+ };
3689
+
3690
+ const act = async (a, m) => {
3691
+ try {
3692
+ if (a === 'connect') await api(`/machines/${m.id}/connect`, { method: 'POST', body: {} });
3693
+ if (a === 'disconnect') await api(`/machines/${m.id}/disconnect`, { method: 'POST', body: {} });
3694
+ if (a === 'projects') openProjects({ machine: m.id, host: sub, focusAdd: true });
3695
+ if (a === 'edit') {
3696
+ editing = m.id;
3697
+ paint();
3698
+ $(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
3699
+ }
3700
+ if (a === 'restart') {
3701
+ 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;
3702
+ await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
3703
+ }
3704
+ if (a === 'stop') {
3705
+ 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;
3706
+ await api(`/machines/${m.id}/stop`, { method: 'POST', body: {} });
3707
+ toast(`Stopped wisp on <b>${esc(m.name)}</b>`);
3708
+ }
3709
+ if (a === 'remove') {
3710
+ 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;
3711
+ await api(`/machines/${m.id}`, { method: 'DELETE' });
3712
+ await loadConfig();
3713
+ await loadMachines();
3714
+ }
3715
+ } catch (err) {
3716
+ toast(esc(err.message), { tone: 'bad' });
3717
+ }
3718
+ };
3719
+
3720
+ node.addEventListener('click', async (e) => {
3721
+ const b = e.target.closest('[data-a]');
3722
+ if (!b || !body.contains(b)) return;
3723
+ const a = b.dataset.a;
3724
+ const m = machineById(b.closest('.mc')?.dataset.id);
3725
+ if (a === 'copy') {
3726
+ copy(b.dataset.cmd);
3727
+ toast('Copied. Run it in a terminal, then connect again.');
3728
+ return;
3729
+ }
3730
+ if (a === 'new') {
3731
+ editing = '';
3732
+ paint();
3733
+ $('.mc-add input[name="target"]', node)?.focus();
3734
+ return;
3735
+ }
3736
+ if (a === 'pick') {
3737
+ // Fill in the form; connecting still waits for the button.
3738
+ editing = '';
3739
+ paint();
3740
+ const f = $('.mc-add form[data-id=""]', node);
3741
+ f.target.value = b.dataset.target;
3742
+ f.name.value = b.dataset.name;
3743
+ $('button[type="submit"]', f).focus();
3744
+ return;
3745
+ }
3746
+ if (a === 'cancel') {
3747
+ editing = null;
3748
+ paint();
3749
+ return;
3750
+ }
3751
+ if (a === 'menu' && m) {
3752
+ menu(m, b);
3753
+ return;
3754
+ }
3755
+ if (m) act(a, m);
3756
+ });
3757
+ // The name follows the host until you type one.
3758
+ node.addEventListener('input', (e) => {
3759
+ if (e.target.name !== 'target') return;
3760
+ const name = $('input[name="name"]', e.target.form);
3761
+ const h = hosts.find((x) => x.target === e.target.value.trim());
3762
+ name.placeholder = (h?.name || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
3763
+ });
3764
+ node.addEventListener('submit', async (e) => {
3765
+ e.preventDefault();
3766
+ const f = e.target;
3767
+ const id = f.dataset.id;
3768
+ const v = Object.fromEntries(new FormData(f));
3769
+ const payload = { target: v.target.trim(), name: v.name.trim(), port: Number(v.port) || 4477, command: v.command.trim(), autoConnect: !!v.autoConnect };
3770
+ const btn = $('button[type="submit"]', f);
3771
+ btn.disabled = true;
3772
+ try {
3773
+ if (id) await api(`/machines/${id}`, { method: 'PATCH', body: payload });
3774
+ else await api('/machines', { method: 'POST', body: payload });
3775
+ editing = null;
3776
+ await loadMachines();
3777
+ paint();
3778
+ } catch (err) {
3779
+ toast(esc(err.message), { tone: 'bad' });
3780
+ } finally {
3781
+ btn.disabled = false;
3782
+ }
3783
+ });
3784
+ node.refresh = () => {
3785
+ // Don't clobber a half-typed form: repaint the cards only.
3786
+ const typing = node.contains(document.activeElement) && document.activeElement.matches('input, select');
3787
+ if (typing && editing !== null && editing !== '') return;
3788
+ if (typing) $('.mc-list', node).innerHTML = S.machines.map(card).join('');
3789
+ else paint();
3790
+ };
3791
+ paint();
3792
+ if (focusAdd && S.machines.length) {
3793
+ editing = '';
3794
+ paint();
3795
+ }
3796
+ if (focusAdd || !S.machines.length) setTimeout(() => $('.mc-add input[name="target"]', node)?.focus(), 60);
3797
+ }
3798
+
3255
3799
  // --- settings -----------------------------------------------------------------------------
3256
3800
 
3257
3801
  // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
@@ -3260,6 +3804,7 @@ const SETTINGS_PANES = [
3260
3804
  { id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
3261
3805
  { id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
3262
3806
  { id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
3807
+ { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
3263
3808
  { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
3264
3809
  { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
3265
3810
  { id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
@@ -3288,7 +3833,7 @@ function openSettings(opts) {
3288
3833
  .join('')}</select>`)}
3289
3834
  ${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
3835
  <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>
3836
+ <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
3837
  </div>
3293
3838
  <div class="sx-group">
3294
3839
  ${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 +3851,12 @@ function openSettings(opts) {
3306
3851
  <div class="sx-group">${S.agents
3307
3852
  .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
3853
  .join('')}</div>
3309
- <p class="hint">Others plug in via <code>server/adapters</code>.</p>
3854
+ </section>
3855
+ <section class="sx-pane" data-pane="machines">
3856
+ <div class="sx-group">
3857
+ ${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
3858
+ </div>
3859
+ <p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
3310
3860
  </section>
3311
3861
  <section class="sx-pane" data-pane="memory">
3312
3862
  <div class="sx-group">
@@ -3462,6 +4012,7 @@ function openSettings(opts) {
3462
4012
  <div><span>Questions</span><b>${x.questions}</b></div>`;
3463
4013
  })
3464
4014
  .catch(() => ($('.sx-usage', node).innerHTML = ''));
4015
+ $('.machines-hint', node).textContent = machinesSummary();
3465
4016
  api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
3466
4017
  paintLearn();
3467
4018
  };
@@ -3470,6 +4021,7 @@ function openSettings(opts) {
3470
4021
  $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
3471
4022
  $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
3472
4023
  $('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
4024
+ $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
3473
4025
  }
3474
4026
 
3475
4027
  // --- memory: what the background retros learned ---------------------------------------------
@@ -3689,7 +4241,7 @@ function paletteItems() {
3689
4241
  if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
3690
4242
  items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
3691
4243
  items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
3692
- if (cur.branch && !live && cur.pr?.state !== 'MERGED') items.push({ group: g, label: cur.pr?.state === 'OPEN' ? `Push to PR #${cur.pr.number}` : 'Ship it…', sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
4244
+ if (shippable(cur)) items.push({ group: g, label: shipLabel(cur), sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
3693
4245
  if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
3694
4246
  if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
3695
4247
  items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
@@ -3728,6 +4280,8 @@ function paletteItems() {
3728
4280
  { group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
3729
4281
  { group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
3730
4282
  { group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
4283
+ { group: a, label: 'Machines', icon: 'server', keywords: 'remote ssh server computer devbox cloud vm', run: () => openMachines() },
4284
+ { group: a, label: 'Add a machine…', icon: 'server', keywords: 'remote ssh server computer connect', run: () => openMachines({ focusAdd: true }) },
3731
4285
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
3732
4286
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
3733
4287
  );
@@ -3772,7 +4326,14 @@ function askNotify() {
3772
4326
  function notifyFinished(s) {
3773
4327
  const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
3774
4328
  if (viewing) return;
3775
- const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
4329
+ let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
4330
+ // A task run in several places (or compared) says so once, when its last run finishes.
4331
+ const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
4332
+ if (runs.length > 1 && s.state !== 'waiting') {
4333
+ if (runs.some((x) => x.id !== s.id && x.state === 'running')) return;
4334
+ const places = new Set(runs.map((x) => x.machine?.id || '')).size;
4335
+ verb = places > 1 ? `is ready on ${places} machines` : `is ready (${runs.length} runs)`;
4336
+ }
3776
4337
  toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
3777
4338
  if (notifyOn() && !document.hasFocus()) {
3778
4339
  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 });
@@ -3803,7 +4364,7 @@ function connect() {
3803
4364
  renderFeed();
3804
4365
  });
3805
4366
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
3806
- if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
4367
+ if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
3807
4368
  });
3808
4369
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
3809
4370
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
@@ -3818,12 +4379,13 @@ function connect() {
3818
4379
  });
3819
4380
  es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
3820
4381
  es.addEventListener('remote', () => $('.phone-modal')?.refresh());
4382
+ es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
3821
4383
  es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
3822
4384
  es.addEventListener('preferences', () => {
3823
4385
  $('.memory-modal')?.refresh();
3824
4386
  $('.settings-modal')?.paintLearn?.();
3825
4387
  });
3826
- es.addEventListener('hello', () => (loadSessions(), loadUpdate())); // also after sleep/reconnect
4388
+ es.addEventListener('hello', () => (loadSessions(), loadUpdate(), loadMachines())); // also after sleep/reconnect
3827
4389
  }
3828
4390
 
3829
4391
  async function loadSessions() {
@@ -3964,7 +4526,7 @@ document.addEventListener('keydown', (e) => {
3964
4526
  deleteSession(id);
3965
4527
  } else if (k === 't' && id) takeOver(id);
3966
4528
  else if (k === 'r' && e.shiftKey && id) rerun(id);
3967
- else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
4529
+ else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
3968
4530
  else if (k === 'r' && id) replay(id);
3969
4531
  else if (k === '/' && id) {
3970
4532
  e.preventDefault();
@@ -3981,6 +4543,7 @@ document.addEventListener('keydown', (e) => {
3981
4543
 
3982
4544
  $('#settingsBtn').onclick = openSettings;
3983
4545
  $('#searchBtn').onclick = openPalette;
4546
+ $('#machinesBtn').onclick = () => openMachines();
3984
4547
  $('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
3985
4548
  $('#updateBtn').onclick = installUpdate;
3986
4549
  $('#searchBtn').title = `Search (${combo('K')})`;
@@ -4046,7 +4609,7 @@ window.addEventListener('focus', () => {
4046
4609
  });
4047
4610
 
4048
4611
  (async () => {
4049
- [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
4612
+ [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
4050
4613
  renderLauncher();
4051
4614
  connect();
4052
4615
  route();