wisp-agents 0.15.0 → 0.17.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 +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +176 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +351 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +216 -49
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +84 -0
- package/web/css/feed.css +235 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +344 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +105 -0
- package/web/js/features/composer.js +315 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +561 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +163 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// The launcher: the box on top where tasks start. It picks where they run (this computer and/or
|
|
2
|
+
// machines, a project in each), on what, and shows a placeholder card until the server answers.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { plain } from '../mention-text.js';
|
|
6
|
+
import { api } from '../core/api.js';
|
|
7
|
+
import * as bus from '../core/bus.js';
|
|
8
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
9
|
+
import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
10
|
+
import { combo } from '../ui/kbd.js';
|
|
11
|
+
import { popover } from '../ui/popover.js';
|
|
12
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
13
|
+
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
14
|
+
import { openAutomations, recipeParams, runRecipe } from './automations.js';
|
|
15
|
+
import { renderFeed } from './feed.js';
|
|
16
|
+
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
17
|
+
import { connectMachine, offlineToast, openMachines } from './machines.js';
|
|
18
|
+
import { mentionBox } from './mentions.js';
|
|
19
|
+
import { askNotify } from './notify.js';
|
|
20
|
+
import { MACHINE_STATE, machineById, placeName, placeOf, projectById, projectsAt, projectTitle } from './places.js';
|
|
21
|
+
import { openProjects } from './projects.js';
|
|
22
|
+
import { runnerPicker } from './runner-picker.js';
|
|
23
|
+
import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
|
|
24
|
+
import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
25
|
+
import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
|
|
26
|
+
import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
27
|
+
|
|
28
|
+
function syncWhere() {
|
|
29
|
+
launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
|
|
30
|
+
if (!launch.where.length) launch.where = [''];
|
|
31
|
+
// Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
|
|
32
|
+
const f = S.filter !== 'all' && projectById(S.filter);
|
|
33
|
+
if (f) {
|
|
34
|
+
if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
|
|
35
|
+
launch.picks[placeOf(f)] = f.id;
|
|
36
|
+
} else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
|
|
37
|
+
for (const k of launch.where) if (!projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
|
|
38
|
+
launch.project = launch.picks[launch.where[0]] || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderLauncher() {
|
|
42
|
+
const box = $('#launcher');
|
|
43
|
+
const mode = S.config.defaults.mode || DEFAULT_MODE.id;
|
|
44
|
+
const here = harnessesAt(launch.where, { auto: true });
|
|
45
|
+
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
|
|
46
|
+
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
47
|
+
box.innerHTML = `
|
|
48
|
+
<div class="l-projects"></div>
|
|
49
|
+
<div class="l-setup" hidden></div>
|
|
50
|
+
<textarea id="prompt" rows="2" spellcheck="false"></textarea>
|
|
51
|
+
<div class="l-bar">
|
|
52
|
+
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
53
|
+
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
54
|
+
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
55
|
+
<div class="runners">
|
|
56
|
+
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
57
|
+
<button class="vs-btn vs-add" id="compareBtn"><span>vs</span>${icon('plus')}</button>
|
|
58
|
+
</div>
|
|
59
|
+
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
60
|
+
<span class="grow"></span>
|
|
61
|
+
<button class="tog-btn" id="wtBtn"></button>
|
|
62
|
+
<button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="l-recipes"></div>`;
|
|
65
|
+
renderLaunchChips();
|
|
66
|
+
renderSetup();
|
|
67
|
+
|
|
68
|
+
const ta = mentionBox($('#prompt', box));
|
|
69
|
+
const tray = attachTray();
|
|
70
|
+
ta.parentNode.before(tray.node); // before the mention box's wrapper
|
|
71
|
+
acceptFiles(box, ta, tray);
|
|
72
|
+
autosize(ta);
|
|
73
|
+
ta.value = S.drafts.get('home') || '';
|
|
74
|
+
ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
|
|
75
|
+
// While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
|
|
76
|
+
const hint = () => (ta.placeholder = document.activeElement === ta
|
|
77
|
+
? 'What should I go do? Paste screenshots, drop logs or videos…'
|
|
78
|
+
: `What should I go do? Press ${combo('L')} to start typing`);
|
|
79
|
+
ta.addEventListener('focus', hint);
|
|
80
|
+
ta.addEventListener('blur', hint);
|
|
81
|
+
hint();
|
|
82
|
+
// Docked and folded to one line, a click anywhere on it opens it up for typing. A property, not
|
|
83
|
+
// a listener: the launcher re-renders in place, and each render would add another.
|
|
84
|
+
box.onmousedown = (e) => {
|
|
85
|
+
if (!box.matches('.docked:not(:focus-within)')) return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
ta.focus();
|
|
88
|
+
};
|
|
89
|
+
$('#attach', box).onclick = tray.pick;
|
|
90
|
+
$('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
|
|
91
|
+
paintWhereBtn();
|
|
92
|
+
|
|
93
|
+
// Mode is set-and-forget, so it's one small button: just an icon on the default (Full auto),
|
|
94
|
+
// spelled out when it's Plan or Edits so you can't miss it.
|
|
95
|
+
let curMode = mode;
|
|
96
|
+
const modeBtn = $('#mode', box);
|
|
97
|
+
const paintMode = () => {
|
|
98
|
+
const m = MODES.find((x) => x.id === curMode) || DEFAULT_MODE;
|
|
99
|
+
modeBtn.className = `mode-btn m-${m.id}`;
|
|
100
|
+
modeBtn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE ? '' : `<span>${m.label}</span>`}`;
|
|
101
|
+
modeBtn.title = `${m.label}: ${m.hint}\nShift+Tab to switch`;
|
|
102
|
+
};
|
|
103
|
+
const setMode = (id) => {
|
|
104
|
+
curMode = id;
|
|
105
|
+
S.config.defaults.mode = id;
|
|
106
|
+
paintMode();
|
|
107
|
+
api('/config', { method: 'PATCH', body: { defaults: { mode: id } } }).catch(() => {});
|
|
108
|
+
};
|
|
109
|
+
paintMode();
|
|
110
|
+
modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
|
|
111
|
+
box.mode = () => curMode;
|
|
112
|
+
let curModel = savedModel(agent?.id);
|
|
113
|
+
let curEffort = savedEffort(agent?.id, curModel);
|
|
114
|
+
const runnerBtn = $('#runner', box);
|
|
115
|
+
const paintRunner = () => {
|
|
116
|
+
paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
|
|
117
|
+
runnerBtn.title = compare.length ? runnerTip(agent?.id, curModel, curEffort) : `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
|
|
121
|
+
const wtBtn = $('#wtBtn', box);
|
|
122
|
+
const paintWt = () => {
|
|
123
|
+
const on = S.config.defaults.worktree !== 'off';
|
|
124
|
+
wtBtn.classList.toggle('on', on);
|
|
125
|
+
wtBtn.innerHTML = `${icon('branch')}<span>${on ? 'Worktree' : 'In place'}</span>`;
|
|
126
|
+
wtBtn.title = on
|
|
127
|
+
? 'Worktree: runs on its own branch from origin/main in a separate checkout, so parallel agents never collide (git repos; not in Plan mode). Click to work in the project folder instead.'
|
|
128
|
+
: 'In place: edits the project folder directly. Click to give each task its own worktree and branch.';
|
|
129
|
+
};
|
|
130
|
+
paintWt();
|
|
131
|
+
wtBtn.onclick = () => {
|
|
132
|
+
const worktree = S.config.defaults.worktree === 'off' ? 'on' : 'off';
|
|
133
|
+
S.config.defaults.worktree = worktree;
|
|
134
|
+
paintWt();
|
|
135
|
+
api('/config', { method: 'PATCH', body: { defaults: { worktree } } }).catch(() => {});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Subagents: remembered like the other picks, so a team stays set until you clear it.
|
|
139
|
+
let team = (S.config.defaults.subagents || []).filter((m) => S.agents.some((a) => a.id === m.agent));
|
|
140
|
+
let teamPreset = S.config.defaults.teamPreset || '';
|
|
141
|
+
const teamBtn = $('#teamBtn', box);
|
|
142
|
+
const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
|
|
143
|
+
paintTeam();
|
|
144
|
+
teamBtn.onclick = () =>
|
|
145
|
+
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset, places: launch.where }, (members, preset) => {
|
|
146
|
+
if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
|
|
147
|
+
team = members;
|
|
148
|
+
teamPreset = preset;
|
|
149
|
+
Object.assign(S.config.defaults, { subagents: team, teamPreset });
|
|
150
|
+
paintTeam();
|
|
151
|
+
api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Compare: more harnesses get the same task (up to MAX_RUNNERS in all); picks here don't touch
|
|
155
|
+
// the launcher defaults. Each extra sits behind a "vs" that removes it; the button at the end
|
|
156
|
+
// adds the next one. Past two, the pill folds the names away and keeps the logos.
|
|
157
|
+
let compare = [];
|
|
158
|
+
const vsBtn = $('#compareBtn', box);
|
|
159
|
+
const runners = vsBtn.parentElement;
|
|
160
|
+
const paintCompare = () => {
|
|
161
|
+
const n = compare.length;
|
|
162
|
+
runners.classList.toggle('cmp', n > 0);
|
|
163
|
+
runners.classList.toggle('many', n > 1);
|
|
164
|
+
for (const x of $$('.cmp-x', runners)) x.remove();
|
|
165
|
+
compare.forEach((c, i) => {
|
|
166
|
+
const name = agentName(c.agent);
|
|
167
|
+
const del = el(`<button class="vs-btn vs-del cmp-x" data-i="${i}" title="Remove ${esc(name)} from the comparison" aria-label="Remove ${esc(name)}"><span>vs</span>${icon('x')}</button>`);
|
|
168
|
+
const btn = el(`<button class="runner-btn cmp-x" data-i="${i}" aria-haspopup="dialog"></button>`);
|
|
169
|
+
paintRunnerBtn(btn, c.agent, c.model, c.effort);
|
|
170
|
+
btn.title = runnerTip(c.agent, c.model, c.effort);
|
|
171
|
+
vsBtn.before(del, btn);
|
|
172
|
+
});
|
|
173
|
+
vsBtn.hidden = n >= MAX_RUNNERS - 1;
|
|
174
|
+
vsBtn.title = n ? `Add another harness (up to ${MAX_RUNNERS} side by side)` : 'Compare: run the same task on another harness, side by side';
|
|
175
|
+
paintRunner(); // its tooltip spells everything out once the names fold away
|
|
176
|
+
};
|
|
177
|
+
vsBtn.onclick = () => {
|
|
178
|
+
// The next harness nobody in the pill uses yet; once they're all in, another copy of the lead.
|
|
179
|
+
const used = new Set([agent?.id, ...compare.map((c) => c.agent)]);
|
|
180
|
+
const here = harnessesAt(launch.where).filter((a) => a.available);
|
|
181
|
+
const next = here.find((a) => !used.has(a.id)) || agent;
|
|
182
|
+
const model = savedModel(next?.id);
|
|
183
|
+
compare = [...compare, { agent: next?.id, model, effort: savedEffort(next?.id, model) }];
|
|
184
|
+
paintCompare();
|
|
185
|
+
// Its "vs" lands right under the pointer: it can't remove anything until the pointer has left.
|
|
186
|
+
const fresh = $$('.vs-del', runners).at(-1);
|
|
187
|
+
fresh?.classList.add('fresh');
|
|
188
|
+
fresh?.addEventListener('pointerleave', () => fresh.classList.remove('fresh'), { once: true });
|
|
189
|
+
// Straight into picking it when it's a repeat, since that's rarely the one you want.
|
|
190
|
+
if (used.has(next?.id)) $$('.runner-btn.cmp-x', runners).at(-1)?.click();
|
|
191
|
+
};
|
|
192
|
+
runners.addEventListener('click', (e) => {
|
|
193
|
+
const x = e.target.closest('.cmp-x');
|
|
194
|
+
if (!x) return;
|
|
195
|
+
const i = Number(x.dataset.i);
|
|
196
|
+
if (x.classList.contains('vs-del')) {
|
|
197
|
+
if (x.classList.contains('fresh')) return;
|
|
198
|
+
compare = compare.filter((_, k) => k !== i);
|
|
199
|
+
paintCompare();
|
|
200
|
+
(compare.length ? $$('.vs-del', runners)[Math.min(i, compare.length - 1)] : vsBtn).focus();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
runnerPicker(x, { ...compare[i], savedModel, savedEffort, places: launch.where }, (id, model, effort) => {
|
|
204
|
+
compare = compare.map((c, k) => (k === i ? { agent: id, model, effort } : c));
|
|
205
|
+
paintCompare();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
paintCompare();
|
|
209
|
+
runnerBtn.onclick = () =>
|
|
210
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launch.where, auto: true }, (id, model, effort) => {
|
|
211
|
+
agent = harnessesAt(launch.where, { auto: true }).find((a) => a.id === id);
|
|
212
|
+
curModel = model;
|
|
213
|
+
curEffort = effort;
|
|
214
|
+
paintRunner();
|
|
215
|
+
paintTeam();
|
|
216
|
+
const d = S.config.defaults;
|
|
217
|
+
const models = { ...d.models, [id]: model };
|
|
218
|
+
// A model without levels leaves the harness's saved level alone, so switching back restores it.
|
|
219
|
+
const efforts = effort || effortsFor(id, model).length ? { ...d.efforts, [id]: effort } : d.efforts;
|
|
220
|
+
Object.assign(d, { agent: id, model, models, effort, efforts });
|
|
221
|
+
api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models, effort, efforts } } }).catch(() => {});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const go = async () => {
|
|
225
|
+
const prompt = ta.value.trim();
|
|
226
|
+
if (!prompt) return ta.focus();
|
|
227
|
+
if (isFeedback(prompt)) {
|
|
228
|
+
ta.value = '';
|
|
229
|
+
ta.dispatchEvent(new Event('input'));
|
|
230
|
+
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
231
|
+
}
|
|
232
|
+
// One run per place picked under "Run on", each in the project picked for it.
|
|
233
|
+
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launch.picks[k]) }));
|
|
234
|
+
const unset = targets.find((t) => !t.project);
|
|
235
|
+
if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
|
|
236
|
+
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
237
|
+
if (off) return offlineToast(off.k);
|
|
238
|
+
if (needsSetup()) return nudgeSetup();
|
|
239
|
+
const wanted = [agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
|
|
240
|
+
for (const t of targets) {
|
|
241
|
+
const list = agentsAt(t.k);
|
|
242
|
+
const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
|
|
243
|
+
if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(placeName(t.k))}</b>`, { tone: 'bad' });
|
|
244
|
+
}
|
|
245
|
+
const btn = $('#launch', box);
|
|
246
|
+
// Optimistic: wait only for uploads, then empty the box and show the task in Working at
|
|
247
|
+
// once. Fetching and making worktrees can take a few seconds; the real card replaces the
|
|
248
|
+
// placeholder when the server has it. On failure the prompt and files come back.
|
|
249
|
+
const attachments = await tray.ids();
|
|
250
|
+
const files = tray.take();
|
|
251
|
+
const cmp = compare;
|
|
252
|
+
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
253
|
+
const pending = targets.map((t) => addPending(prompt, t, runner));
|
|
254
|
+
if (cmp.length) (compare = []), paintCompare();
|
|
255
|
+
ta.value = '';
|
|
256
|
+
S.drafts.delete('home');
|
|
257
|
+
ta.dispatchEvent(new Event('input'));
|
|
258
|
+
if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
|
|
259
|
+
S.filter = 'all';
|
|
260
|
+
save('wisp.filter', S.filter);
|
|
261
|
+
}
|
|
262
|
+
renderFeed();
|
|
263
|
+
const ghost = S.nodes.get(pending[0])?.card;
|
|
264
|
+
showLaunched(btn, ghost);
|
|
265
|
+
ta.focus();
|
|
266
|
+
try {
|
|
267
|
+
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
268
|
+
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
269
|
+
const results = await Promise.allSettled(
|
|
270
|
+
targets.map((t) =>
|
|
271
|
+
api('/sessions', {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
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.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
results.forEach((r, i) => {
|
|
278
|
+
if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
|
|
279
|
+
});
|
|
280
|
+
const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
281
|
+
for (const id of pending) S.sessions.delete(id);
|
|
282
|
+
for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
|
|
283
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
284
|
+
if (!started.length) {
|
|
285
|
+
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
286
|
+
tray.restore(files);
|
|
287
|
+
if (cmp.length && !compare.length) (compare = cmp), paintCompare();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
askNotify();
|
|
291
|
+
} catch (err) {
|
|
292
|
+
for (const id of pending) S.sessions.delete(id);
|
|
293
|
+
renderFeed();
|
|
294
|
+
toastError(err);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
$('#launch', box).onclick = go;
|
|
298
|
+
// Picking other places (or a machine coming online) changes which harnesses exist: keep the
|
|
299
|
+
// pick if one of them still has it, else move to one that's installed there.
|
|
300
|
+
box.placesChanged = () => {
|
|
301
|
+
renderSetup();
|
|
302
|
+
const here = harnessesAt(launch.where, { auto: true }); // Auto works anywhere: it picks from what's there
|
|
303
|
+
if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
|
|
304
|
+
const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
|
|
305
|
+
if (!next) return paintRunner();
|
|
306
|
+
agent = next;
|
|
307
|
+
curModel = savedModel(agent.id);
|
|
308
|
+
curEffort = savedEffort(agent.id, curModel);
|
|
309
|
+
paintRunner();
|
|
310
|
+
paintTeam();
|
|
311
|
+
};
|
|
312
|
+
ta.addEventListener('keydown', (e) => {
|
|
313
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
go();
|
|
316
|
+
}
|
|
317
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
setMode(MODES[(MODES.findIndex((m) => m.id === curMode) + 1) % MODES.length].id);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function renderLaunchChips() {
|
|
325
|
+
const box = $('#launcher .l-projects');
|
|
326
|
+
if (!box) return;
|
|
327
|
+
syncWhere();
|
|
328
|
+
const multi = launch.where.length > 1;
|
|
329
|
+
// One group per place: its name (unless it's only this computer), its projects, a way to add one.
|
|
330
|
+
const group = (k) => {
|
|
331
|
+
const m = k && machineById(k);
|
|
332
|
+
const list = projectsAt(k);
|
|
333
|
+
const off = m && m.state !== 'online';
|
|
334
|
+
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>` : '';
|
|
335
|
+
const chips = list.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launch.picks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
|
|
336
|
+
const add = list.length
|
|
337
|
+
? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi ? '' : 'Other folder…'}</button>`
|
|
338
|
+
: `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a project folder…'}</button>`;
|
|
339
|
+
return `<span class="lp-group">${label}${chips}${add}</span>`;
|
|
340
|
+
};
|
|
341
|
+
box.innerHTML = `<span class="l-in">in</span>${launch.where.map(group).join('')}`;
|
|
342
|
+
box.onclick = (e) => {
|
|
343
|
+
const chip = e.target.closest('.lp-chip[data-id]');
|
|
344
|
+
if (chip) {
|
|
345
|
+
launch.picks[chip.dataset.k] = chip.dataset.id;
|
|
346
|
+
if (chip.dataset.k === launch.where[0]) launch.project = chip.dataset.id;
|
|
347
|
+
saveLaunch();
|
|
348
|
+
$$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
|
|
349
|
+
renderLaunchRecipes();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const add = e.target.closest('[data-add]');
|
|
353
|
+
if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
|
|
354
|
+
const conn = e.target.closest('[data-connect]');
|
|
355
|
+
if (conn) connectMachine(conn.dataset.connect);
|
|
356
|
+
};
|
|
357
|
+
renderLaunchRecipes();
|
|
358
|
+
paintWhereBtn();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// "Run on": this computer and/or machines. The button says where; its menu picks one or several.
|
|
362
|
+
function paintWhereBtn() {
|
|
363
|
+
const b = $('#whereBtn');
|
|
364
|
+
if (!b) return;
|
|
365
|
+
const n = launch.where.length;
|
|
366
|
+
const remote = n > 1 || !!launch.where[0];
|
|
367
|
+
b.classList.toggle('on', remote);
|
|
368
|
+
b.classList.toggle('bare', !S.machines.length && !remote);
|
|
369
|
+
b.innerHTML = `${icon(remote ? 'server' : 'laptop')}<span>${esc(n > 1 ? `${n} places` : placeName(launch.where[0]))}</span>${icon('chevron', 'chev')}`;
|
|
370
|
+
b.title = n > 1 ? `Runs in parallel on ${launch.where.map(placeName).join(', ')}` : S.machines.length ? `Runs on ${placeName(launch.where[0])}. Pick several to run in parallel.` : 'Runs on this computer. Add a machine to run on other computers too.';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function whereMenu(anchor) {
|
|
374
|
+
if ($('.menu.where-menu')) return $('.menu.where-menu').close();
|
|
375
|
+
const box = el(`<div class="menu where-menu" role="menu"></div>`);
|
|
376
|
+
const paint = () => {
|
|
377
|
+
const row = (k, m) => {
|
|
378
|
+
const on = launch.where.includes(k);
|
|
379
|
+
const st = m ? m.state : 'online';
|
|
380
|
+
const ready = m ? (m.remote?.agents || []).filter((a) => a.available) : [];
|
|
381
|
+
return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
|
|
382
|
+
<span class="wm-check">${icon('check')}</span>
|
|
383
|
+
<span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
|
|
384
|
+
<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>
|
|
385
|
+
${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
|
|
386
|
+
<span class="wm-only" data-only="${k}" title="Only here">only</span>
|
|
387
|
+
</button>`;
|
|
388
|
+
};
|
|
389
|
+
box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel</small></div>
|
|
390
|
+
${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
|
|
391
|
+
<div class="wm-sep"></div>
|
|
392
|
+
<button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
|
|
393
|
+
${S.machines.length ? `<button data-a="manage">${icon('settings')}Manage machines</button>` : ''}`;
|
|
394
|
+
};
|
|
395
|
+
const set = (list) => {
|
|
396
|
+
launch.where = list.length ? list : [''];
|
|
397
|
+
// A machine picked while disconnected starts connecting; you asked to run there.
|
|
398
|
+
for (const k of launch.where) if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
399
|
+
renderLaunchChips();
|
|
400
|
+
$('#launcher')?.placesChanged?.();
|
|
401
|
+
paint();
|
|
402
|
+
saveLaunch();
|
|
403
|
+
};
|
|
404
|
+
box.onclick = (e) => {
|
|
405
|
+
const only = e.target.closest('[data-only]');
|
|
406
|
+
if (only) return set([only.dataset.only]);
|
|
407
|
+
const r = e.target.closest('.wm-row');
|
|
408
|
+
if (r) {
|
|
409
|
+
const k = r.dataset.k;
|
|
410
|
+
return set(launch.where.includes(k) ? launch.where.filter((x) => x !== k) : [...launch.where, k]);
|
|
411
|
+
}
|
|
412
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
413
|
+
if (!a) return;
|
|
414
|
+
shut();
|
|
415
|
+
openMachines({ focusAdd: a === 'add' });
|
|
416
|
+
};
|
|
417
|
+
paint();
|
|
418
|
+
const shut = popover(box, { anchor, onClose: () => anchor.setAttribute('aria-expanded', 'false') });
|
|
419
|
+
bus.on('machines', paint, { node: box });
|
|
420
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
421
|
+
const r = anchor.getBoundingClientRect();
|
|
422
|
+
const h = box.offsetHeight;
|
|
423
|
+
box.style.left = `${Math.min(r.left, innerWidth - box.offsetWidth - 8)}px`;
|
|
424
|
+
box.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Recipes for the picked project (and the ones tied to none), one click from the launcher.
|
|
428
|
+
// With text in the box, "Save as recipe" turns it into one.
|
|
429
|
+
function renderLaunchRecipes() {
|
|
430
|
+
const row = $('#launcher .l-recipes');
|
|
431
|
+
if (!row) return;
|
|
432
|
+
const path = projectById(launch.project)?.path;
|
|
433
|
+
const all = S.config.recipes || [];
|
|
434
|
+
const list = all.filter((r) => !r.cwd || r.cwd === path).sort((a, b) => (b.lastRunAt || 0) - (a.lastRunAt || 0));
|
|
435
|
+
const hidden = all.length - list.length;
|
|
436
|
+
row.innerHTML = `
|
|
437
|
+
<span class="l-in">${icon('spark')}Recipes</span>
|
|
438
|
+
${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('')}
|
|
439
|
+
${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>`}
|
|
440
|
+
<button class="lp-chip add" id="lrSave" hidden>${icon('plus')}Save as recipe</button>
|
|
441
|
+
<button class="lp-chip add" id="lrNew">${icon('plus')}New</button>`;
|
|
442
|
+
row.onclick = (e) => {
|
|
443
|
+
const b = e.target.closest('button');
|
|
444
|
+
if (!b) return;
|
|
445
|
+
if (b.dataset.recipe) {
|
|
446
|
+
const r = all.find((x) => x.id === b.dataset.recipe);
|
|
447
|
+
return r && runRecipe({ ...r, params: recipeParams(r.prompt) });
|
|
448
|
+
}
|
|
449
|
+
const draft = { name: '', prompt: '', cwd: path || '', mode: $('#launcher').mode?.() };
|
|
450
|
+
if (b.id === 'lrSave') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { ...draft, prompt: $('#prompt').value.trim() } } });
|
|
451
|
+
if (b.id === 'lrNew') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft } });
|
|
452
|
+
};
|
|
453
|
+
paintSaveRecipe();
|
|
454
|
+
}
|
|
455
|
+
function paintSaveRecipe() {
|
|
456
|
+
const has = !!$('#prompt')?.value.trim();
|
|
457
|
+
const save = $('#lrSave');
|
|
458
|
+
if (!save) return;
|
|
459
|
+
save.hidden = !has;
|
|
460
|
+
$('#lrNew').hidden = has;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A little wisp flies from the launch button to the new item in the feed.
|
|
464
|
+
function launchedFx(from, to) {
|
|
465
|
+
to?.classList.add('arrived');
|
|
466
|
+
setTimeout(() => to?.classList.remove('arrived'), 1600);
|
|
467
|
+
if (!to || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
468
|
+
const a = from.getBoundingClientRect();
|
|
469
|
+
const b = to.getBoundingClientRect();
|
|
470
|
+
const w = el(`<div class="flying-wisp"></div>`);
|
|
471
|
+
w.style.left = `${a.left + a.width / 2}px`;
|
|
472
|
+
w.style.top = `${a.top + a.height / 2}px`;
|
|
473
|
+
document.body.append(w);
|
|
474
|
+
const dx = b.left + 24 - (a.left + a.width / 2);
|
|
475
|
+
const dy = b.top + b.height / 2 - (a.top + a.height / 2);
|
|
476
|
+
w.animate(
|
|
477
|
+
[
|
|
478
|
+
{ transform: 'translate(-50%,-50%) scale(.6)', opacity: 0 },
|
|
479
|
+
{ transform: `translate(calc(-50% + ${dx * 0.4}px), calc(-50% + ${dy * 0.4 - 60}px)) scale(1.3)`, opacity: 1, offset: 0.35 },
|
|
480
|
+
{ transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(.4)`, opacity: 0 },
|
|
481
|
+
],
|
|
482
|
+
{ duration: 800, easing: 'cubic-bezier(.5,0,.3,1)' },
|
|
483
|
+
).onfinish = () => w.remove();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
487
|
+
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
488
|
+
let pendingSeq = 0;
|
|
489
|
+
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
490
|
+
const id = `pending-${++pendingSeq}`;
|
|
491
|
+
const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
S.sessions.set(id, {
|
|
494
|
+
id,
|
|
495
|
+
pending: true,
|
|
496
|
+
title: line.length > 64 ? `${line.slice(0, 63)}…` : line,
|
|
497
|
+
agent,
|
|
498
|
+
model,
|
|
499
|
+
effort,
|
|
500
|
+
cwd: p.path,
|
|
501
|
+
project: p.name,
|
|
502
|
+
projectId: p.id,
|
|
503
|
+
color: p.color || 'slate',
|
|
504
|
+
state: 'running',
|
|
505
|
+
live: 'Starting up',
|
|
506
|
+
createdAt: now,
|
|
507
|
+
updatedAt: now,
|
|
508
|
+
machine: m ? { id: m.id, name: m.name, target: m.target } : null,
|
|
509
|
+
});
|
|
510
|
+
return id;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
|
|
514
|
+
// at once; the real session replaces it, or it disappears and the error is thrown on.
|
|
515
|
+
export async function launchBehind({ prompt, cwd, runner }, request) {
|
|
516
|
+
const sum = S.config.projects.find((p) => p.path === cwd);
|
|
517
|
+
const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
|
|
518
|
+
const ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
|
|
519
|
+
if (S.filter !== 'all' && S.filter !== project.id) {
|
|
520
|
+
S.filter = 'all';
|
|
521
|
+
save('wisp.filter', S.filter);
|
|
522
|
+
}
|
|
523
|
+
renderFeed();
|
|
524
|
+
const card = S.nodes.get(ghost)?.card;
|
|
525
|
+
if (card) (card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), card.classList.add('arrived'), setTimeout(() => card.classList.remove('arrived'), 1600));
|
|
526
|
+
try {
|
|
527
|
+
const s = await request();
|
|
528
|
+
S.sessions.delete(ghost);
|
|
529
|
+
for (const x of s.group || [s]) S.sessions.set(x.id, x);
|
|
530
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
531
|
+
return s;
|
|
532
|
+
} catch (err) {
|
|
533
|
+
S.sessions.delete(ghost);
|
|
534
|
+
renderFeed();
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// The real session showed up (live events can beat the launch response): drop its placeholder.
|
|
540
|
+
export function dropPending(s) {
|
|
541
|
+
if (s.pending || s.parentId) return;
|
|
542
|
+
for (const x of S.sessions.values()) if (x.pending && x.projectId === s.projectId) return S.sessions.delete(x.id);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
|
|
546
|
+
// it into view (just enough) and flash it; the flying wisp only plays when the card is already
|
|
547
|
+
// on screen, since the launcher it starts from may scroll away or fold into the dock.
|
|
548
|
+
function showLaunched(btn, card) {
|
|
549
|
+
if (!card) return;
|
|
550
|
+
const r = card.getBoundingClientRect();
|
|
551
|
+
if (r.top >= topInset() && r.bottom <= innerHeight) return launchedFx(btn, card);
|
|
552
|
+
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
553
|
+
card.classList.add('arrived');
|
|
554
|
+
setTimeout(() => card.classList.remove('arrived'), 1600);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function focusLauncher() {
|
|
558
|
+
// Docked under the top bar it opens where it is, so you keep your place in the feed.
|
|
559
|
+
if (!$('#launcher').classList.contains('docked')) stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
560
|
+
$('#prompt')?.focus({ preventScroll: true });
|
|
561
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Live events from the server (SSE) and the loads they trigger.
|
|
2
|
+
|
|
3
|
+
import { esc } from '../md.js';
|
|
4
|
+
import { api } from '../core/api.js';
|
|
5
|
+
import * as bus from '../core/bus.js';
|
|
6
|
+
import { $ } from '../core/dom.js';
|
|
7
|
+
import { S, saveOpen } from '../core/state.js';
|
|
8
|
+
import { toast } from '../ui/toast.js';
|
|
9
|
+
import { paintAutoBtn } from './automations.js';
|
|
10
|
+
import { collapse, renderFeed, scheduleRefetch, sectionOf } from './feed.js';
|
|
11
|
+
import { dropPending, renderLaunchChips } from './launcher.js';
|
|
12
|
+
import { loadMachines, setMachines } from './machines.js';
|
|
13
|
+
import { notifyFinished } from './notify.js';
|
|
14
|
+
import { renderProjects } from './projects.js';
|
|
15
|
+
import { feedAnchor, keepInPlace, watching } from './scroll.js';
|
|
16
|
+
import { openSettings } from './settings.js';
|
|
17
|
+
import { renderPrStrip } from './ship.js';
|
|
18
|
+
import { onChildSummary } from './team.js';
|
|
19
|
+
import { onActivity } from './turns.js';
|
|
20
|
+
import { loadUpdate, renderUpdate } from './update.js';
|
|
21
|
+
|
|
22
|
+
export function connect() {
|
|
23
|
+
const es = new EventSource('/api/events');
|
|
24
|
+
es.addEventListener('summary', (e) => {
|
|
25
|
+
const s = JSON.parse(e.data);
|
|
26
|
+
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
27
|
+
const prev = S.sessions.get(s.id);
|
|
28
|
+
if (!prev) dropPending(s);
|
|
29
|
+
const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
|
|
30
|
+
S.sessions.set(s.id, s);
|
|
31
|
+
// A section change moves the card (a follow-up sends it from "Needs you" to "Working").
|
|
32
|
+
// If the user is watching it, the view follows it there; otherwise whatever they're
|
|
33
|
+
// reading stays put. Marking done or archiving is the exception: the card leaves the
|
|
34
|
+
// list and focus goes to the next one instead.
|
|
35
|
+
const moved = prev && was !== sectionOf(s);
|
|
36
|
+
const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
|
|
37
|
+
keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), () => {
|
|
38
|
+
// A card left open while it worked shouldn't land in "Needs you" expanded —
|
|
39
|
+
// fold it unless the user is actually watching it.
|
|
40
|
+
if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
|
|
41
|
+
renderFeed();
|
|
42
|
+
});
|
|
43
|
+
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
44
|
+
if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
|
|
45
|
+
});
|
|
46
|
+
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
47
|
+
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
48
|
+
es.addEventListener('finished', (e) => notifyFinished(JSON.parse(e.data).session));
|
|
49
|
+
es.addEventListener('removed', (e) => {
|
|
50
|
+
const { id } = JSON.parse(e.data);
|
|
51
|
+
S.sessions.delete(id);
|
|
52
|
+
S.full.delete(id);
|
|
53
|
+
S.open.delete(id);
|
|
54
|
+
saveOpen();
|
|
55
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
56
|
+
});
|
|
57
|
+
es.addEventListener('config', () => (loadConfig(), bus.emit('config')));
|
|
58
|
+
es.addEventListener('remote', () => bus.emit('remote'));
|
|
59
|
+
es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
|
|
60
|
+
es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
|
|
61
|
+
// A new harness or model Auto can use: it gets a few tasks before Auto relies on it.
|
|
62
|
+
es.addEventListener('router', (e) => {
|
|
63
|
+
const { added = [] } = JSON.parse(e.data || '{}');
|
|
64
|
+
bus.emit('router');
|
|
65
|
+
if (added.length) toast(`New for Auto: <b>${added.map((a) => esc(a.label)).join('</b>, <b>')}</b>${added.some((a) => a.state === 'new') ? ' · it will try it on a few tasks' : ''}`, { action: { label: 'Auto settings', run: () => openSettings({ pane: 'auto' }) }, timeout: 8000 });
|
|
66
|
+
});
|
|
67
|
+
// A harness was installed from wisp (maybe in another tab): fetch the new list.
|
|
68
|
+
es.addEventListener('agents', async () => {
|
|
69
|
+
S.agents = await api('/agents').catch(() => S.agents);
|
|
70
|
+
$('#launcher')?.placesChanged?.();
|
|
71
|
+
bus.emit('agents');
|
|
72
|
+
});
|
|
73
|
+
es.addEventListener('preferences', () => bus.emit('preferences'));
|
|
74
|
+
es.addEventListener('hello', () => (loadSessions(), loadUpdate(), loadMachines())); // also after sleep/reconnect
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function loadSessions() {
|
|
78
|
+
const list = await api('/sessions');
|
|
79
|
+
S.sessions = new Map(list.map((s) => [s.id, s]));
|
|
80
|
+
for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
|
|
81
|
+
saveOpen();
|
|
82
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
83
|
+
for (const id of S.open) scheduleRefetch(id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function loadConfig() {
|
|
87
|
+
S.config = await api('/config');
|
|
88
|
+
renderProjects();
|
|
89
|
+
renderLaunchChips();
|
|
90
|
+
paintAutoBtn();
|
|
91
|
+
}
|