wisp-agents 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/server/git.js +6 -3
- package/server/index.js +44 -2
- package/server/machines.js +72 -2
- package/server/openapi.js +1 -0
- package/server/runner.js +1 -0
- package/server/update.js +8 -4
- package/web/apple-touch-icon.png +0 -0
- package/web/css/app.css +13 -0
- package/web/favicon.svg +12 -4
- package/web/index.html +1 -0
- package/web/js/app.js +369 -120
package/web/js/app.js
CHANGED
|
@@ -64,6 +64,28 @@ const MODES = [
|
|
|
64
64
|
const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
|
|
65
65
|
const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
|
|
66
66
|
const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
|
|
67
|
+
// Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
|
|
68
|
+
const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
|
|
69
|
+
// A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
|
|
70
|
+
function agentById(id) {
|
|
71
|
+
const local = S.agents.find((a) => a.id === id);
|
|
72
|
+
if (local?.available) return local;
|
|
73
|
+
return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
|
|
74
|
+
}
|
|
75
|
+
// The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
|
|
76
|
+
// `missing` names the picked places that don't have it. Machines not online yet don't count.
|
|
77
|
+
function harnessesAt(places) {
|
|
78
|
+
const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
|
|
79
|
+
if (!known.length) known.push(['', S.agents]);
|
|
80
|
+
const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
|
|
81
|
+
return ids
|
|
82
|
+
.map((id) => {
|
|
83
|
+
const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
|
|
84
|
+
const base = hits.length ? hits[0][1].find((a) => a.id === id) : S.agents.find((a) => a.id === id) || known.flatMap(([, l]) => l).find((a) => a.id === id);
|
|
85
|
+
return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
|
|
86
|
+
})
|
|
87
|
+
.filter((a) => a.available || S.agents.some((x) => x.id === a.id));
|
|
88
|
+
}
|
|
67
89
|
|
|
68
90
|
// Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
|
|
69
91
|
// `mono` marks are drawn in the text colour so they work in both themes.
|
|
@@ -83,14 +105,14 @@ function harnessLogo(agent) {
|
|
|
83
105
|
// Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
|
|
84
106
|
// else the harness-wide one. Empty means the harness has no knob for it.
|
|
85
107
|
function effortsFor(agent, model) {
|
|
86
|
-
const a =
|
|
108
|
+
const a = agentById(agent);
|
|
87
109
|
return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
|
|
88
110
|
}
|
|
89
111
|
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
90
112
|
|
|
91
113
|
function modelBadge(agent, model, effort) {
|
|
92
114
|
if (agent === 'wisp') return '';
|
|
93
|
-
const label = (model &&
|
|
115
|
+
const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
94
116
|
const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
95
117
|
const logo = harnessLogo(agent);
|
|
96
118
|
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
@@ -294,14 +316,17 @@ function item(s) {
|
|
|
294
316
|
n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
|
|
295
317
|
S.nodes.set(s.id, n);
|
|
296
318
|
n.head.addEventListener('click', (e) => {
|
|
297
|
-
if (n.head.querySelector('[contenteditable="true"]')) return;
|
|
319
|
+
if (s.pending || n.head.querySelector('[contenteditable="true"]')) return;
|
|
298
320
|
if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
|
|
299
321
|
setFocus(s.id);
|
|
300
322
|
toggle(s.id);
|
|
301
323
|
});
|
|
302
|
-
card.addEventListener('mousedown', () =>
|
|
324
|
+
card.addEventListener('mousedown', () => {
|
|
325
|
+
if (!s.pending) setFocus(s.id, { scroll: false });
|
|
326
|
+
});
|
|
303
327
|
n.head.addEventListener('contextmenu', (e) => {
|
|
304
328
|
e.preventDefault();
|
|
329
|
+
if (s.pending) return;
|
|
305
330
|
setFocus(s.id, { scroll: false });
|
|
306
331
|
taskMenu(s.id, e);
|
|
307
332
|
});
|
|
@@ -322,7 +347,7 @@ function renderHead(n, s) {
|
|
|
322
347
|
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
323
348
|
: '';
|
|
324
349
|
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
325
|
-
n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
350
|
+
n.card.className = `item ${s.pending ? 'pending' : ''} c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
326
351
|
n.head.innerHTML = `
|
|
327
352
|
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
328
353
|
<span class="it-main">
|
|
@@ -528,6 +553,17 @@ function attachTray() {
|
|
|
528
553
|
items.length = 0;
|
|
529
554
|
render();
|
|
530
555
|
},
|
|
556
|
+
// Empty the tray for an optimistic send, and put the files back if the send fails.
|
|
557
|
+
take() {
|
|
558
|
+
const list = items.splice(0);
|
|
559
|
+
render();
|
|
560
|
+
return list;
|
|
561
|
+
},
|
|
562
|
+
restore(list) {
|
|
563
|
+
if (!list?.length) return;
|
|
564
|
+
items.unshift(...list);
|
|
565
|
+
render();
|
|
566
|
+
},
|
|
531
567
|
get busy() {
|
|
532
568
|
return items.some((i) => i.status === 'uploading');
|
|
533
569
|
},
|
|
@@ -606,7 +642,7 @@ function syncWhere() {
|
|
|
606
642
|
function savedModel(id) {
|
|
607
643
|
const d = S.config.defaults;
|
|
608
644
|
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
609
|
-
const models =
|
|
645
|
+
const models = agentById(id)?.models || [];
|
|
610
646
|
return models.some((m) => m.id === want) ? want : '';
|
|
611
647
|
}
|
|
612
648
|
// Thinking level too, and only if the chosen model still accepts it.
|
|
@@ -619,21 +655,21 @@ function savedEffort(id, model) {
|
|
|
619
655
|
function renderLauncher() {
|
|
620
656
|
const box = $('#launcher');
|
|
621
657
|
const mode = S.config.defaults.mode || 'edits';
|
|
622
|
-
const
|
|
623
|
-
const chosen =
|
|
624
|
-
let agent = chosen?.available ? chosen :
|
|
658
|
+
const here = harnessesAt(launchWhere);
|
|
659
|
+
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'claude'));
|
|
660
|
+
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
625
661
|
box.innerHTML = `
|
|
626
662
|
<div class="l-projects"></div>
|
|
627
663
|
<textarea id="prompt" rows="2" spellcheck="false"></textarea>
|
|
628
664
|
<div class="l-bar">
|
|
629
665
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
666
|
+
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
630
667
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
631
668
|
<div class="runners">
|
|
632
669
|
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
633
670
|
<button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
|
|
634
671
|
<button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
|
|
635
672
|
</div>
|
|
636
|
-
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
637
673
|
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
638
674
|
<span class="grow"></span>
|
|
639
675
|
<button class="tog-btn" id="wtBtn"></button>
|
|
@@ -719,7 +755,7 @@ function renderLauncher() {
|
|
|
719
755
|
const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
|
|
720
756
|
paintTeam();
|
|
721
757
|
teamBtn.onclick = () =>
|
|
722
|
-
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
|
|
758
|
+
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset, places: launchWhere }, (members, preset) => {
|
|
723
759
|
if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
|
|
724
760
|
team = members;
|
|
725
761
|
teamPreset = preset;
|
|
@@ -743,19 +779,19 @@ function renderLauncher() {
|
|
|
743
779
|
vsBtn.onclick = () => {
|
|
744
780
|
if (compare) compare = null;
|
|
745
781
|
else {
|
|
746
|
-
const other =
|
|
782
|
+
const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
|
|
747
783
|
compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
|
|
748
784
|
}
|
|
749
785
|
paintCompare();
|
|
750
786
|
};
|
|
751
787
|
runner2.onclick = () =>
|
|
752
|
-
runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
|
|
788
|
+
runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
753
789
|
compare = { agent: id, model, effort };
|
|
754
790
|
paintCompare();
|
|
755
791
|
});
|
|
756
792
|
runnerBtn.onclick = () =>
|
|
757
|
-
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
758
|
-
agent =
|
|
793
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
794
|
+
agent = harnessesAt(launchWhere).find((a) => a.id === id);
|
|
759
795
|
curModel = model;
|
|
760
796
|
curEffort = effort;
|
|
761
797
|
paintRunner();
|
|
@@ -779,21 +815,39 @@ function renderLauncher() {
|
|
|
779
815
|
if (off) return offlineToast(off.k);
|
|
780
816
|
const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
|
|
781
817
|
for (const t of targets) {
|
|
782
|
-
const
|
|
783
|
-
|
|
818
|
+
const list = agentsAt(t.k);
|
|
819
|
+
const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
|
|
820
|
+
if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(placeName(t.k))}</b>`, { tone: 'bad' });
|
|
784
821
|
}
|
|
785
822
|
const btn = $('#launch', box);
|
|
786
|
-
|
|
787
|
-
|
|
823
|
+
// Optimistic: wait only for uploads, then empty the box and show the task in Working at
|
|
824
|
+
// once. Fetching and making worktrees can take a few seconds; the real card replaces the
|
|
825
|
+
// placeholder when the server has it. On failure the prompt and files come back.
|
|
826
|
+
const attachments = await tray.ids();
|
|
827
|
+
const files = tray.take();
|
|
828
|
+
const cmp = compare;
|
|
829
|
+
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
830
|
+
const pending = targets.map((t) => addPending(prompt, t, runner));
|
|
831
|
+
if (compare) (compare = null), paintCompare();
|
|
832
|
+
ta.value = '';
|
|
833
|
+
S.drafts.delete('home');
|
|
834
|
+
ta.dispatchEvent(new Event('input'));
|
|
835
|
+
if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
|
|
836
|
+
S.filter = 'all';
|
|
837
|
+
save('wisp.filter', S.filter);
|
|
838
|
+
}
|
|
839
|
+
renderFeed();
|
|
840
|
+
const ghost = S.nodes.get(pending[0])?.card;
|
|
841
|
+
showLaunched(btn, ghost);
|
|
842
|
+
ta.focus();
|
|
788
843
|
try {
|
|
789
|
-
const attachments = await tray.ids();
|
|
790
844
|
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
791
845
|
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
792
846
|
const results = await Promise.allSettled(
|
|
793
847
|
targets.map((t) =>
|
|
794
848
|
api('/sessions', {
|
|
795
849
|
method: 'POST',
|
|
796
|
-
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent
|
|
850
|
+
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ? [cmp] : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
797
851
|
}),
|
|
798
852
|
),
|
|
799
853
|
);
|
|
@@ -801,31 +855,36 @@ function renderLauncher() {
|
|
|
801
855
|
if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
|
|
802
856
|
});
|
|
803
857
|
const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
804
|
-
|
|
858
|
+
for (const id of pending) S.sessions.delete(id);
|
|
805
859
|
for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
|
|
813
|
-
S.filter = 'all';
|
|
814
|
-
save('wisp.filter', S.filter);
|
|
860
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
861
|
+
if (!started.length) {
|
|
862
|
+
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
863
|
+
tray.restore(files);
|
|
864
|
+
if (cmp && !compare) (compare = cmp), paintCompare();
|
|
865
|
+
return;
|
|
815
866
|
}
|
|
816
|
-
renderFeed();
|
|
817
|
-
const card = S.nodes.get(s.id)?.card;
|
|
818
|
-
showLaunched(btn, card?.closest('.task-item') || card);
|
|
819
867
|
askNotify();
|
|
820
868
|
} catch (err) {
|
|
869
|
+
for (const id of pending) S.sessions.delete(id);
|
|
870
|
+
renderFeed();
|
|
821
871
|
toast(esc(err.message), { tone: 'bad' });
|
|
822
|
-
} finally {
|
|
823
|
-
btn.disabled = false;
|
|
824
|
-
btn.classList.remove('busy');
|
|
825
|
-
ta.focus();
|
|
826
872
|
}
|
|
827
873
|
};
|
|
828
874
|
$('#launch', box).onclick = launch;
|
|
875
|
+
// Picking other places (or a machine coming online) changes which harnesses exist: keep the
|
|
876
|
+
// pick if one of them still has it, else move to one that's installed there.
|
|
877
|
+
box.placesChanged = () => {
|
|
878
|
+
const here = harnessesAt(launchWhere);
|
|
879
|
+
if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
|
|
880
|
+
const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
|
|
881
|
+
if (!next) return paintRunner();
|
|
882
|
+
agent = next;
|
|
883
|
+
curModel = savedModel(agent.id);
|
|
884
|
+
curEffort = savedEffort(agent.id, curModel);
|
|
885
|
+
paintRunner();
|
|
886
|
+
paintTeam();
|
|
887
|
+
};
|
|
829
888
|
ta.addEventListener('keydown', (e) => {
|
|
830
889
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
831
890
|
e.preventDefault();
|
|
@@ -840,7 +899,7 @@ function renderLauncher() {
|
|
|
840
899
|
|
|
841
900
|
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
842
901
|
function paintRunnerBtn(btn, agentId, model, effort) {
|
|
843
|
-
const a =
|
|
902
|
+
const a = agentById(agentId);
|
|
844
903
|
const m = (a?.models || []).find((x) => x.id === model);
|
|
845
904
|
btn.innerHTML = agentId
|
|
846
905
|
? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
|
|
@@ -856,9 +915,9 @@ const MAX_COPIES = 5; // parallel copies of one row
|
|
|
856
915
|
const MAX_INSTANCES = 12; // copies across the team
|
|
857
916
|
const copiesOf = (m) => Math.max(1, m.count || 1);
|
|
858
917
|
const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
|
|
859
|
-
const canLead = (id) =>
|
|
918
|
+
const canLead = (id) => agentById(id)?.delegates !== false;
|
|
860
919
|
const memberLabel = (m) => {
|
|
861
|
-
const a =
|
|
920
|
+
const a = agentById(m.agent);
|
|
862
921
|
const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
|
|
863
922
|
return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
|
|
864
923
|
};
|
|
@@ -905,7 +964,7 @@ function paintTeamBtn(btn, team, presetId, lead) {
|
|
|
905
964
|
: `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
|
|
906
965
|
}
|
|
907
966
|
|
|
908
|
-
function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
967
|
+
function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
|
|
909
968
|
const open = $('.team-pop');
|
|
910
969
|
if (open) return open.close();
|
|
911
970
|
let members = team.map((m) => ({ ...m }));
|
|
@@ -969,7 +1028,8 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
|
969
1028
|
const add = () => {
|
|
970
1029
|
if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
|
|
971
1030
|
const used = new Set([lead, ...members.map((m) => m.agent)]);
|
|
972
|
-
const
|
|
1031
|
+
const pool = places ? harnessesAt(places) : S.agents;
|
|
1032
|
+
const a = pool.find((x) => x.available && !used.has(x.id)) || pool.find((x) => x.available && x.id !== lead) || pool.find((x) => x.available);
|
|
973
1033
|
if (!a) return toast('No harness is installed.', { tone: 'bad' });
|
|
974
1034
|
const model = savedModel(a.id);
|
|
975
1035
|
members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
|
|
@@ -1072,7 +1132,7 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
|
1072
1132
|
const rb = e.target.closest('.tp-runner');
|
|
1073
1133
|
if (rb) {
|
|
1074
1134
|
const m = members[i];
|
|
1075
|
-
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
|
|
1135
|
+
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
|
|
1076
1136
|
Object.assign(m, { agent, model, effort });
|
|
1077
1137
|
paintList();
|
|
1078
1138
|
commit();
|
|
@@ -1349,6 +1409,7 @@ function whereMenu(anchor) {
|
|
|
1349
1409
|
// A machine picked while disconnected starts connecting; you asked to run there.
|
|
1350
1410
|
for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
1351
1411
|
renderLaunchChips();
|
|
1412
|
+
$('#launcher')?.placesChanged?.();
|
|
1352
1413
|
paint();
|
|
1353
1414
|
saveWhere();
|
|
1354
1415
|
};
|
|
@@ -1467,6 +1528,65 @@ function launchedFx(from, to) {
|
|
|
1467
1528
|
).onfinish = () => w.remove();
|
|
1468
1529
|
}
|
|
1469
1530
|
|
|
1531
|
+
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
1532
|
+
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
1533
|
+
let pendingSeq = 0;
|
|
1534
|
+
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
1535
|
+
const id = `pending-${++pendingSeq}`;
|
|
1536
|
+
const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
|
|
1537
|
+
const now = Date.now();
|
|
1538
|
+
S.sessions.set(id, {
|
|
1539
|
+
id,
|
|
1540
|
+
pending: true,
|
|
1541
|
+
title: line.length > 64 ? `${line.slice(0, 63)}…` : line,
|
|
1542
|
+
agent,
|
|
1543
|
+
model,
|
|
1544
|
+
effort,
|
|
1545
|
+
cwd: p.path,
|
|
1546
|
+
project: p.name,
|
|
1547
|
+
projectId: p.id,
|
|
1548
|
+
color: p.color || 'slate',
|
|
1549
|
+
state: 'running',
|
|
1550
|
+
live: 'Starting up',
|
|
1551
|
+
createdAt: now,
|
|
1552
|
+
updatedAt: now,
|
|
1553
|
+
machine: m ? { id: m.id, name: m.name, target: m.target } : null,
|
|
1554
|
+
});
|
|
1555
|
+
return id;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
|
|
1559
|
+
// at once; the real session replaces it, or it disappears and the error is thrown on.
|
|
1560
|
+
async function launchBehind({ prompt, cwd, runner }, request) {
|
|
1561
|
+
const sum = S.config.projects.find((p) => p.path === cwd);
|
|
1562
|
+
const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
|
|
1563
|
+
const ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
|
|
1564
|
+
if (S.filter !== 'all' && S.filter !== project.id) {
|
|
1565
|
+
S.filter = 'all';
|
|
1566
|
+
save('wisp.filter', S.filter);
|
|
1567
|
+
}
|
|
1568
|
+
renderFeed();
|
|
1569
|
+
const card = S.nodes.get(ghost)?.card;
|
|
1570
|
+
if (card) (card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), card.classList.add('arrived'), setTimeout(() => card.classList.remove('arrived'), 1600));
|
|
1571
|
+
try {
|
|
1572
|
+
const s = await request();
|
|
1573
|
+
S.sessions.delete(ghost);
|
|
1574
|
+
for (const x of s.group || [s]) S.sessions.set(x.id, x);
|
|
1575
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
1576
|
+
return s;
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
S.sessions.delete(ghost);
|
|
1579
|
+
renderFeed();
|
|
1580
|
+
throw err;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// The real session showed up (live events can beat the launch response): drop its placeholder.
|
|
1585
|
+
function dropPending(s) {
|
|
1586
|
+
if (s.pending || s.parentId) return;
|
|
1587
|
+
for (const x of S.sessions.values()) if (x.pending && x.projectId === s.projectId) return S.sessions.delete(x.id);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1470
1590
|
// A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
|
|
1471
1591
|
// it into view (just enough) and flash it; the flying wisp only plays when the card is already
|
|
1472
1592
|
// on screen, since the launcher it starts from may scroll away or fold into the dock.
|
|
@@ -1633,7 +1753,6 @@ function renderBody(id, { fresh }) {
|
|
|
1633
1753
|
${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>` : ''}`;
|
|
1634
1754
|
const live = s.state === 'running' || s.state === 'waiting';
|
|
1635
1755
|
// Ship sits in the top bar and again next to the composer, so it's there after reading the result.
|
|
1636
|
-
$$('[data-a="ship"]', body).forEach((b) => (b.hidden = !s.worktree || live || s.agent === 'wisp' || s.pr?.state === 'MERGED'));
|
|
1637
1756
|
renderPrStrip(id);
|
|
1638
1757
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
1639
1758
|
b.classList.toggle('on', s.state === 'done');
|
|
@@ -1808,8 +1927,18 @@ function renderWorking(s, t) {
|
|
|
1808
1927
|
.filter((a) => a.kind !== 'text')
|
|
1809
1928
|
.slice(-5)
|
|
1810
1929
|
.forEach((a) => ticker.prepend(actItem(a)));
|
|
1811
|
-
$('.stop', node).onclick = async () => {
|
|
1812
|
-
|
|
1930
|
+
$('.stop', node).onclick = async (e) => {
|
|
1931
|
+
// Shows at once; the run winds down and the live update replaces this panel.
|
|
1932
|
+
const btn = e.currentTarget;
|
|
1933
|
+
btn.disabled = true;
|
|
1934
|
+
btn.lastChild.textContent = 'Stopping…';
|
|
1935
|
+
$('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
|
|
1936
|
+
await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
|
|
1937
|
+
btn.disabled = false;
|
|
1938
|
+
btn.lastChild.textContent = 'Stop';
|
|
1939
|
+
$('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
|
|
1940
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
1941
|
+
});
|
|
1813
1942
|
};
|
|
1814
1943
|
$('.peek', node).onclick = (e) => {
|
|
1815
1944
|
const log = $('.log', node);
|
|
@@ -1837,7 +1966,15 @@ function renderAskCard(s) {
|
|
|
1837
1966
|
const answer = async (text) => {
|
|
1838
1967
|
if (!text.trim()) return;
|
|
1839
1968
|
node.classList.add('sent');
|
|
1840
|
-
|
|
1969
|
+
const undo = startOptimistic(s.id, text, null, { answering: true });
|
|
1970
|
+
try {
|
|
1971
|
+
await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
|
|
1972
|
+
if (undo) scheduleRefetch(s.id);
|
|
1973
|
+
} catch (e) {
|
|
1974
|
+
node.classList.remove('sent');
|
|
1975
|
+
undo?.();
|
|
1976
|
+
toast(esc(e.message), { tone: 'bad' });
|
|
1977
|
+
}
|
|
1841
1978
|
};
|
|
1842
1979
|
node.addEventListener('click', (e) => {
|
|
1843
1980
|
const b = e.target.closest('.opt');
|
|
@@ -2064,21 +2201,58 @@ async function sendReply(id, text) {
|
|
|
2064
2201
|
if (!s || !text) return;
|
|
2065
2202
|
if (n?.tray?.busy) toast('Waiting for uploads…');
|
|
2066
2203
|
const attachments = n?.tray ? await n.tray.ids() : [];
|
|
2204
|
+
const answering = !!s.ask && !attachments.length;
|
|
2205
|
+
// Optimistic: the composer empties and the card goes to Working before the server answers
|
|
2206
|
+
// (it may first have to bring back an archived worktree, or reach another machine).
|
|
2207
|
+
const ta = n && $('.reply', n.card);
|
|
2208
|
+
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2209
|
+
S.drafts.delete(id);
|
|
2210
|
+
const files = n?.tray?.take();
|
|
2211
|
+
const undo = startOptimistic(id, text, files, { answering });
|
|
2067
2212
|
try {
|
|
2068
|
-
const r =
|
|
2213
|
+
const r = answering
|
|
2069
2214
|
? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
|
|
2070
2215
|
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
|
|
2071
|
-
|
|
2072
|
-
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2073
|
-
S.drafts.delete(id);
|
|
2074
|
-
n?.tray?.clear();
|
|
2216
|
+
if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
|
|
2075
2217
|
if (r.queued)
|
|
2076
2218
|
toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
|
|
2077
2219
|
} catch (err) {
|
|
2220
|
+
undo?.();
|
|
2221
|
+
// Give the message back, unless the user has already started typing another one.
|
|
2222
|
+
if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
|
|
2223
|
+
n?.tray?.restore(files);
|
|
2078
2224
|
toast(esc(err.message), { tone: 'bad' });
|
|
2079
2225
|
}
|
|
2080
2226
|
}
|
|
2081
2227
|
|
|
2228
|
+
// Show a follow-up as started right away: the card moves to Working with the prompt as a live
|
|
2229
|
+
// turn. Only when it will start now, not when it queues or interrupts a run already working.
|
|
2230
|
+
// Returns an undo for a failed send, or null when nothing changed.
|
|
2231
|
+
function startOptimistic(id, text, files, { answering }) {
|
|
2232
|
+
const full = S.full.get(id);
|
|
2233
|
+
const sum = S.sessions.get(id);
|
|
2234
|
+
if (!full || !sum) return null;
|
|
2235
|
+
const live = full.state === 'running' || full.state === 'waiting';
|
|
2236
|
+
if (live && !answering) return null;
|
|
2237
|
+
const now = Date.now();
|
|
2238
|
+
const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
|
|
2239
|
+
const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn] };
|
|
2240
|
+
const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now };
|
|
2241
|
+
const apply = (f, sm) => {
|
|
2242
|
+
S.full.set(id, f);
|
|
2243
|
+
S.sessions.set(id, sm);
|
|
2244
|
+
const card = S.nodes.get(id)?.card;
|
|
2245
|
+
keepInPlace(watching(id) ? card : feedAnchor(id), renderFeed);
|
|
2246
|
+
renderBody(id, { fresh: false });
|
|
2247
|
+
};
|
|
2248
|
+
apply(nextFull, nextSum);
|
|
2249
|
+
return () => {
|
|
2250
|
+
// A live update may already have replaced the guess; only roll back what is still ours.
|
|
2251
|
+
if (S.full.get(id) !== nextFull || S.sessions.get(id) !== nextSum) return scheduleRefetch(id);
|
|
2252
|
+
apply(full, sum);
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2082
2256
|
// --- item actions ------------------------------------------------------------------
|
|
2083
2257
|
|
|
2084
2258
|
async function toggleDone(id) {
|
|
@@ -2086,23 +2260,48 @@ async function toggleDone(id) {
|
|
|
2086
2260
|
if (!s) return;
|
|
2087
2261
|
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
2088
2262
|
const next = s.state === 'done' ? 'review' : 'done';
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
if (
|
|
2092
|
-
|
|
2093
|
-
|
|
2263
|
+
// Optimistic: the card moves now; a failed save puts it back.
|
|
2264
|
+
move(id, { ...s, state: next }, next === 'done');
|
|
2265
|
+
if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
|
|
2266
|
+
try {
|
|
2267
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
revertSummary(id, s);
|
|
2270
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2094
2271
|
}
|
|
2095
2272
|
}
|
|
2096
2273
|
|
|
2274
|
+
// Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
|
|
2275
|
+
// hands focus to the next card; coming back just re-renders around the reader.
|
|
2276
|
+
function move(id, next, leaving) {
|
|
2277
|
+
if (leaving) return handOff(id)(() => S.sessions.set(id, next));
|
|
2278
|
+
S.sessions.set(id, next);
|
|
2279
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// Put back a summary changed optimistically, unless a live update has replaced it since.
|
|
2283
|
+
function revertSummary(id, prev) {
|
|
2284
|
+
const cur = S.sessions.get(id);
|
|
2285
|
+
if (cur && cur.updatedAt > prev.updatedAt) return;
|
|
2286
|
+
S.sessions.set(id, prev);
|
|
2287
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2097
2290
|
// Every finished run of a compare task at once, with one toast to undo them all.
|
|
2098
2291
|
async function taskDone(ids) {
|
|
2099
2292
|
if (!ids.length) return;
|
|
2100
2293
|
const card = S.nodes.get(ids[0])?.card.closest('.task-item');
|
|
2101
2294
|
const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
|
|
2102
|
-
const set = (state) =>
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2295
|
+
const set = (state) => {
|
|
2296
|
+
const prev = ids.map((id) => S.sessions.get(id));
|
|
2297
|
+
for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
|
|
2298
|
+
renderFeed();
|
|
2299
|
+
Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
|
|
2300
|
+
prev.forEach((p, i) => p && revertSummary(ids[i], p));
|
|
2301
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2302
|
+
});
|
|
2303
|
+
};
|
|
2304
|
+
set('done');
|
|
2106
2305
|
if (next && S.nodes.has(next)) setFocus(next);
|
|
2107
2306
|
toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
|
|
2108
2307
|
}
|
|
@@ -2183,10 +2382,17 @@ function rename(id) {
|
|
|
2183
2382
|
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
2184
2383
|
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
2185
2384
|
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
2186
|
-
|
|
2385
|
+
// places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
|
|
2386
|
+
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places }, pick) {
|
|
2187
2387
|
const open = $('.picker');
|
|
2188
2388
|
if (open) return open.close();
|
|
2189
|
-
|
|
2389
|
+
const agents = places ? harnessesAt(places) : S.agents;
|
|
2390
|
+
const sub = (a) => {
|
|
2391
|
+
if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
|
|
2392
|
+
const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
|
|
2393
|
+
return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
|
|
2394
|
+
};
|
|
2395
|
+
let focusAgent = agents.find((a) => a.id === agent && a.available) ? agent : agents.find((a) => a.available)?.id;
|
|
2190
2396
|
|
|
2191
2397
|
const r = anchor.getBoundingClientRect();
|
|
2192
2398
|
const width = Math.min(580, innerWidth - 24);
|
|
@@ -2199,11 +2405,11 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
2199
2405
|
<div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
2200
2406
|
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
2201
2407
|
<div class="pk-head">Harness</div>
|
|
2202
|
-
${
|
|
2408
|
+
${agents
|
|
2203
2409
|
.map(
|
|
2204
2410
|
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
|
|
2205
2411
|
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
2206
|
-
<span class="pk-text"><b>${esc(a.name)}</b><small
|
|
2412
|
+
<span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
|
|
2207
2413
|
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
2208
2414
|
</button>`,
|
|
2209
2415
|
)
|
|
@@ -2247,7 +2453,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
2247
2453
|
};
|
|
2248
2454
|
|
|
2249
2455
|
const paintModels = () => {
|
|
2250
|
-
const a =
|
|
2456
|
+
const a = agents.find((x) => x.id === focusAgent);
|
|
2251
2457
|
sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
|
|
2252
2458
|
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
2253
2459
|
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
@@ -2438,7 +2644,7 @@ function taskMenu(id, e) {
|
|
|
2438
2644
|
${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
|
|
2439
2645
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
2440
2646
|
<button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
|
|
2441
|
-
${s
|
|
2647
|
+
${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
|
|
2442
2648
|
${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
|
|
2443
2649
|
${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
|
|
2444
2650
|
<button data-a="recipe">${icon('spark')}Save as recipe…</button>
|
|
@@ -2477,9 +2683,12 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
|
2477
2683
|
|
|
2478
2684
|
async function deleteSession(id) {
|
|
2479
2685
|
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
2480
|
-
const
|
|
2481
|
-
|
|
2482
|
-
|
|
2686
|
+
const prev = S.sessions.get(id);
|
|
2687
|
+
handOff(id)(() => S.sessions.delete(id));
|
|
2688
|
+
await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
|
|
2689
|
+
if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
|
|
2690
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2691
|
+
});
|
|
2483
2692
|
}
|
|
2484
2693
|
|
|
2485
2694
|
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
@@ -2538,22 +2747,13 @@ async function rerun(id) {
|
|
|
2538
2747
|
const go = async () => {
|
|
2539
2748
|
const prompt = ta.value.trim();
|
|
2540
2749
|
if (!prompt) return ta.focus();
|
|
2541
|
-
|
|
2542
|
-
btn.disabled = true;
|
|
2750
|
+
close();
|
|
2543
2751
|
try {
|
|
2544
|
-
const
|
|
2545
|
-
|
|
2546
|
-
close();
|
|
2547
|
-
if (S.filter !== 'all' && S.filter !== next.projectId) {
|
|
2548
|
-
S.filter = 'all';
|
|
2549
|
-
save('wisp.filter', S.filter);
|
|
2550
|
-
}
|
|
2551
|
-
renderFeed();
|
|
2752
|
+
const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
2753
|
+
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
|
|
2552
2754
|
setFocus(next.id);
|
|
2553
|
-
toast(`Rerunning on ${esc(agentName(agent))}`);
|
|
2554
2755
|
} catch (err) {
|
|
2555
|
-
|
|
2556
|
-
toast(esc(err.message), { tone: 'bad' });
|
|
2756
|
+
toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
|
|
2557
2757
|
}
|
|
2558
2758
|
};
|
|
2559
2759
|
$('[data-a="go"]', node).onclick = go;
|
|
@@ -2569,15 +2769,16 @@ async function rerun(id) {
|
|
|
2569
2769
|
}
|
|
2570
2770
|
|
|
2571
2771
|
async function setArchived(id, archived) {
|
|
2572
|
-
const
|
|
2772
|
+
const prev = S.sessions.get(id);
|
|
2773
|
+
if (!prev) return;
|
|
2774
|
+
// Optimistic: the card leaves (or comes back) now; a failed save puts it back.
|
|
2775
|
+
move(id, { ...prev, archived }, archived);
|
|
2776
|
+
if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
2573
2777
|
try {
|
|
2574
2778
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
2575
2779
|
} catch (err) {
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
if (leave) {
|
|
2579
|
-
leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
|
|
2580
|
-
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
2780
|
+
revertSummary(id, prev);
|
|
2781
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2581
2782
|
}
|
|
2582
2783
|
}
|
|
2583
2784
|
|
|
@@ -2837,6 +3038,15 @@ function prLine(pr) {
|
|
|
2837
3038
|
const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
|
|
2838
3039
|
return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
|
|
2839
3040
|
}
|
|
3041
|
+
// Ship it only makes sense on a finished worktree task with something to ship: uncommitted
|
|
3042
|
+
// changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
|
|
3043
|
+
function shippable(s) {
|
|
3044
|
+
if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
|
|
3045
|
+
const t = s.toShip;
|
|
3046
|
+
return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
|
|
3047
|
+
}
|
|
3048
|
+
const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
3049
|
+
|
|
2840
3050
|
const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
|
|
2841
3051
|
|
|
2842
3052
|
// The PR row under a card's bar; reads the live summary, so polled check results show up.
|
|
@@ -2846,7 +3056,10 @@ function renderPrStrip(id) {
|
|
|
2846
3056
|
const s = S.sessions.get(id);
|
|
2847
3057
|
if (!node || !s) return;
|
|
2848
3058
|
const pr = s.pr;
|
|
2849
|
-
$$('[data-a="ship"]
|
|
3059
|
+
$$('[data-a="ship"]', n.body).forEach((b) => {
|
|
3060
|
+
b.hidden = !shippable(s);
|
|
3061
|
+
$('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
3062
|
+
});
|
|
2850
3063
|
node.hidden = !pr;
|
|
2851
3064
|
if (!pr) return;
|
|
2852
3065
|
const tone = prTone(pr);
|
|
@@ -2997,16 +3210,21 @@ async function compareView(id) {
|
|
|
2997
3210
|
if (a === 'open') return close(), reveal(keep);
|
|
2998
3211
|
const others = group.filter((g) => g.id !== keep);
|
|
2999
3212
|
if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
|
|
3213
|
+
// Optimistic: the others leave the feed now; stopping and cleaning them up goes on behind.
|
|
3214
|
+
for (const o of others) S.sessions.set(o.id, { ...S.sessions.get(o.id), archived: true });
|
|
3215
|
+
close();
|
|
3216
|
+
renderFeed();
|
|
3217
|
+
reveal(keep);
|
|
3218
|
+
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3219
|
+
let failed = false;
|
|
3000
3220
|
for (const o of others) {
|
|
3001
3221
|
if (o.state === 'running' || o.state === 'waiting') {
|
|
3002
3222
|
await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
|
|
3003
3223
|
await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
|
|
3004
3224
|
}
|
|
3005
|
-
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3225
|
+
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => ((failed = true), toast(esc(err.message), { tone: 'bad' })));
|
|
3006
3226
|
}
|
|
3007
|
-
|
|
3008
|
-
reveal(keep);
|
|
3009
|
-
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3227
|
+
if (failed) loadSessions(); // show what really got archived
|
|
3010
3228
|
};
|
|
3011
3229
|
}
|
|
3012
3230
|
|
|
@@ -3271,18 +3489,17 @@ function runRecipe(r) {
|
|
|
3271
3489
|
const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
|
|
3272
3490
|
const d = S.config.defaults;
|
|
3273
3491
|
const agent = r.agent || d.agent;
|
|
3492
|
+
const cwd = needsProject ? $('[name="cwd"]', node).value : r.cwd;
|
|
3493
|
+
const model = r.agent ? r.model : savedModel(agent);
|
|
3494
|
+
const effort = r.agent ? r.effort : savedEffort(agent, model);
|
|
3495
|
+
const body = { params, cwd: needsProject ? cwd : undefined, agent, model: r.agent ? undefined : model, effort: r.agent ? undefined : effort, mode: r.mode || d.mode };
|
|
3496
|
+
close();
|
|
3274
3497
|
try {
|
|
3275
|
-
const
|
|
3276
|
-
|
|
3277
|
-
body: { params, cwd: needsProject ? $('[name="cwd"]', node).value : undefined, agent, model: r.agent ? undefined : savedModel(agent), effort: r.agent ? undefined : savedEffort(agent, savedModel(agent)), mode: r.mode || d.mode },
|
|
3278
|
-
});
|
|
3279
|
-
S.sessions.set(s.id, s);
|
|
3280
|
-
close();
|
|
3281
|
-
renderFeed();
|
|
3498
|
+
const prompt = r.prompt.replace(/\{([a-zA-Z][\w-]{0,30})\}/g, (_, k) => params[k] || ''); // as the server fills it
|
|
3499
|
+
const s = await launchBehind({ prompt, cwd, runner: { agent, model, effort } }, () => api(`/recipes/${r.id}/run`, { method: 'POST', body }));
|
|
3282
3500
|
setFocus(s.id);
|
|
3283
|
-
toast(`Running “${esc(r.name)}”`);
|
|
3284
3501
|
} catch (err) {
|
|
3285
|
-
toast(esc(err.message)
|
|
3502
|
+
toast(`“${esc(r.name)}” didn’t start: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Try again', run: () => runRecipe(r) } });
|
|
3286
3503
|
}
|
|
3287
3504
|
};
|
|
3288
3505
|
// Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
|
|
@@ -3489,6 +3706,7 @@ function setMachines(list) {
|
|
|
3489
3706
|
S.machines = list;
|
|
3490
3707
|
paintMachinesBtn();
|
|
3491
3708
|
renderLaunchChips();
|
|
3709
|
+
$('#launcher')?.placesChanged?.();
|
|
3492
3710
|
renderProjects();
|
|
3493
3711
|
$('.machines-modal')?.refresh();
|
|
3494
3712
|
$('.projects-modal')?.refresh?.();
|
|
@@ -3539,14 +3757,31 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3539
3757
|
const close = present(node, host);
|
|
3540
3758
|
const body = $('.mc-body', node);
|
|
3541
3759
|
let editing = null; // machine id whose form is open, '' = a new one, null = closed
|
|
3542
|
-
let hosts = [];
|
|
3543
|
-
api('/machines/
|
|
3760
|
+
let hosts = []; // found on this computer: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts
|
|
3761
|
+
api('/machines/discover')
|
|
3544
3762
|
.then((h) => {
|
|
3545
3763
|
hosts = h;
|
|
3764
|
+
const slot = $('.mc-found-slot', node);
|
|
3765
|
+
if (slot) slot.innerHTML = found();
|
|
3546
3766
|
const dl = $('#sshHosts', node);
|
|
3547
|
-
if (dl) dl.innerHTML =
|
|
3767
|
+
if (dl) dl.innerHTML = datalist();
|
|
3548
3768
|
})
|
|
3549
3769
|
.catch(() => {});
|
|
3770
|
+
const datalist = () => hosts.map((x) => `<option value="${esc(x.target)}">${esc(x.detail || '')}</option>`).join('');
|
|
3771
|
+
const SOURCE = { 'ssh-config': '~/.ssh/config', tailscale: 'Tailscale', 'known-hosts': 'You’ve SSHed here before' };
|
|
3772
|
+
// Hosts not added yet, one click from a filled-in form.
|
|
3773
|
+
const found = () => {
|
|
3774
|
+
const added = new Set(S.machines.map((m) => m.target));
|
|
3775
|
+
const list = hosts.filter((x) => !added.has(x.target));
|
|
3776
|
+
if (!list.length) return '';
|
|
3777
|
+
return `<div class="mc-found"><h4>Found on this computer</h4><div class="mc-found-list">${list
|
|
3778
|
+
.slice(0, 12)
|
|
3779
|
+
.map(
|
|
3780
|
+
(x) =>
|
|
3781
|
+
`<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>`,
|
|
3782
|
+
)
|
|
3783
|
+
.join('')}</div></div>`;
|
|
3784
|
+
};
|
|
3550
3785
|
|
|
3551
3786
|
// Projects on a machine open here, with a way back.
|
|
3552
3787
|
const sub = (view) => {
|
|
@@ -3596,7 +3831,7 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3596
3831
|
</div>
|
|
3597
3832
|
${
|
|
3598
3833
|
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>`
|
|
3834
|
+
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${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
3835
|
: ''
|
|
3601
3836
|
}
|
|
3602
3837
|
${busy || st === 'error' ? steps(m) : ''}
|
|
@@ -3630,8 +3865,10 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3630
3865
|
const add = $('.mc-add', node);
|
|
3631
3866
|
// The form for a new machine: open when there are none yet, else behind a button.
|
|
3632
3867
|
if (!S.machines.length || editing === '') {
|
|
3633
|
-
|
|
3634
|
-
|
|
3868
|
+
// Keep a half-filled form; only the suggestions above it change.
|
|
3869
|
+
if ($('form[data-id=""]', add)) $('.mc-found-slot', add).innerHTML = found();
|
|
3870
|
+
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>`;
|
|
3871
|
+
} 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>`;
|
|
3635
3872
|
};
|
|
3636
3873
|
|
|
3637
3874
|
const menu = (m, anchor) => {
|
|
@@ -3667,8 +3904,9 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3667
3904
|
paint();
|
|
3668
3905
|
$(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
|
|
3669
3906
|
}
|
|
3670
|
-
if (a === 'restart') {
|
|
3671
|
-
|
|
3907
|
+
if (a === 'restart' || a === 'update') {
|
|
3908
|
+
const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
|
|
3909
|
+
if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
|
|
3672
3910
|
await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
|
|
3673
3911
|
}
|
|
3674
3912
|
if (a === 'stop') {
|
|
@@ -3703,6 +3941,16 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3703
3941
|
$('.mc-add input[name="target"]', node)?.focus();
|
|
3704
3942
|
return;
|
|
3705
3943
|
}
|
|
3944
|
+
if (a === 'pick') {
|
|
3945
|
+
// Fill in the form; connecting still waits for the button.
|
|
3946
|
+
editing = '';
|
|
3947
|
+
paint();
|
|
3948
|
+
const f = $('.mc-add form[data-id=""]', node);
|
|
3949
|
+
f.target.value = b.dataset.target;
|
|
3950
|
+
f.name.value = b.dataset.name;
|
|
3951
|
+
$('button[type="submit"]', f).focus();
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3706
3954
|
if (a === 'cancel') {
|
|
3707
3955
|
editing = null;
|
|
3708
3956
|
paint();
|
|
@@ -3718,8 +3966,8 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3718
3966
|
node.addEventListener('input', (e) => {
|
|
3719
3967
|
if (e.target.name !== 'target') return;
|
|
3720
3968
|
const name = $('input[name="name"]', e.target.form);
|
|
3721
|
-
const h = hosts.find((x) => x.
|
|
3722
|
-
name.placeholder = (h?.
|
|
3969
|
+
const h = hosts.find((x) => x.target === e.target.value.trim());
|
|
3970
|
+
name.placeholder = (h?.name || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
|
|
3723
3971
|
});
|
|
3724
3972
|
node.addEventListener('submit', async (e) => {
|
|
3725
3973
|
e.preventDefault();
|
|
@@ -4201,7 +4449,7 @@ function paletteItems() {
|
|
|
4201
4449
|
if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
|
|
4202
4450
|
items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
|
|
4203
4451
|
items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
|
|
4204
|
-
if (cur
|
|
4452
|
+
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) });
|
|
4205
4453
|
if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
|
|
4206
4454
|
if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
|
|
4207
4455
|
items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
|
|
@@ -4210,7 +4458,7 @@ function paletteItems() {
|
|
|
4210
4458
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
4211
4459
|
}
|
|
4212
4460
|
|
|
4213
|
-
const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
4461
|
+
const sessions = [...S.sessions.values()].filter((s) => !s.pending).sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
4214
4462
|
for (const s of sessions) {
|
|
4215
4463
|
const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
|
|
4216
4464
|
items.push({
|
|
@@ -4296,7 +4544,7 @@ function notifyFinished(s) {
|
|
|
4296
4544
|
}
|
|
4297
4545
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
4298
4546
|
if (notifyOn() && !document.hasFocus()) {
|
|
4299
|
-
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/
|
|
4547
|
+
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
|
|
4300
4548
|
note.onclick = () => (window.focus(), reveal(s.id));
|
|
4301
4549
|
}
|
|
4302
4550
|
}
|
|
@@ -4309,6 +4557,7 @@ function connect() {
|
|
|
4309
4557
|
const s = JSON.parse(e.data);
|
|
4310
4558
|
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
4311
4559
|
const prev = S.sessions.get(s.id);
|
|
4560
|
+
if (!prev) dropPending(s);
|
|
4312
4561
|
const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
|
|
4313
4562
|
S.sessions.set(s.id, s);
|
|
4314
4563
|
// A section change moves the card (a follow-up sends it from "Needs you" to "Working").
|
|
@@ -4324,7 +4573,7 @@ function connect() {
|
|
|
4324
4573
|
renderFeed();
|
|
4325
4574
|
});
|
|
4326
4575
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
4327
|
-
if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
|
|
4576
|
+
if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
|
|
4328
4577
|
});
|
|
4329
4578
|
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
4330
4579
|
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
@@ -4408,7 +4657,7 @@ async function installUpdate() {
|
|
|
4408
4657
|
|
|
4409
4658
|
// --- keyboard ---------------------------------------------------------------------------
|
|
4410
4659
|
|
|
4411
|
-
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
4660
|
+
const feedIds = () => $$('#feed .item:not(.pending)').map((x) => x.dataset.id);
|
|
4412
4661
|
// The user takes a card out of its list (done, archive, delete). The next card — the one below,
|
|
4413
4662
|
// or the one above at the end of the list — gets focus and lands where the old one sat, so
|
|
4414
4663
|
// triage continues on the same spot. Call before the request, while the card is still laid
|
|
@@ -4486,7 +4735,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
4486
4735
|
deleteSession(id);
|
|
4487
4736
|
} else if (k === 't' && id) takeOver(id);
|
|
4488
4737
|
else if (k === 'r' && e.shiftKey && id) rerun(id);
|
|
4489
|
-
else if (k === 's' && id && S.sessions.get(id)
|
|
4738
|
+
else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
|
|
4490
4739
|
else if (k === 'r' && id) replay(id);
|
|
4491
4740
|
else if (k === '/' && id) {
|
|
4492
4741
|
e.preventDefault();
|