wisp-agents 0.21.0 → 0.22.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +13 -8
  3. package/bin/wisp.js +10 -3
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +8 -4
  6. package/server/adapters/codex.js +9 -6
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +19 -0
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/openapi.js +27 -2
  18. package/server/remote.js +4 -2
  19. package/server/routes/machines.js +1 -1
  20. package/server/routes/proxy.js +6 -0
  21. package/server/routes/sessions.js +32 -2
  22. package/server/routes/settings.js +24 -38
  23. package/server/routes/uploads.js +2 -0
  24. package/server/runner.js +154 -8
  25. package/server/services/launch.js +32 -4
  26. package/server/services/move.js +130 -0
  27. package/server/services/phone.js +20 -1
  28. package/server/services/places.js +150 -0
  29. package/server/services/uploads.js +23 -4
  30. package/server/store.js +39 -6
  31. package/skills/wisp/SKILL.md +1 -0
  32. package/web/css/blocks.css +2 -0
  33. package/web/css/composer.css +23 -1
  34. package/web/css/feed.css +49 -1
  35. package/web/css/machines.css +4 -0
  36. package/web/css/modals.css +19 -0
  37. package/web/css/ship.css +2 -0
  38. package/web/css/transcript.css +115 -0
  39. package/web/index.html +2 -0
  40. package/web/js/core/state.js +3 -2
  41. package/web/js/features/actions.js +42 -10
  42. package/web/js/features/btw.js +145 -0
  43. package/web/js/features/clones.js +7 -6
  44. package/web/js/features/commands.js +3 -1
  45. package/web/js/features/composer.js +162 -18
  46. package/web/js/features/feed.js +1 -1
  47. package/web/js/features/keys.js +2 -1
  48. package/web/js/features/launcher.js +104 -94
  49. package/web/js/features/place-menu.js +44 -0
  50. package/web/js/features/places.js +15 -5
  51. package/web/js/features/projects.js +14 -12
  52. package/web/js/features/settings.js +24 -3
  53. package/web/js/features/slash.js +63 -0
  54. package/web/js/features/transcript.js +290 -0
  55. package/web/js/features/turns.js +99 -28
  56. package/web/js/slash-text.js +22 -0
  57. package/web/js/ui/kbd.js +1 -0
@@ -17,7 +17,10 @@ import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
17
17
  import { launchBehind } from './launcher.js';
18
18
  import { copyTaskId, mentionBox } from './mentions.js';
19
19
  import { runnerPicker } from './runner-picker.js';
20
- import { agentName, DEFAULT_MODE, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
20
+ import { connectMachine } from './machines.js';
21
+ import { placeMenu } from './place-menu.js';
22
+ import { machineById, pathAt, placeName, projectById } from './places.js';
23
+ import { agentById, agentName, DEFAULT_MODE, effortsFor, harnessesAt, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
21
24
  import { feedAnchor, keepInPlace } from './scroll.js';
22
25
  import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
23
26
 
@@ -221,15 +224,18 @@ export async function deleteSession(id) {
221
224
  });
222
225
  }
223
226
 
224
- // Launch the task again as a new session in the same folder: same prompt and attachments,
225
- // optionally edited, on whichever harness, model and thinking level you pick. The original stays.
227
+ // Launch the task again as a new session in the same project: same prompt and attachments,
228
+ // optionally edited, on whichever computer, harness, model and thinking level you pick. The
229
+ // original stays. Another computer runs it in its own checkout (cloned there if need be).
226
230
  export async function rerun(id) {
227
231
  const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(toastError));
228
232
  const first = s?.turns?.[0];
229
233
  if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
230
- const has = (a) => S.agents.some((x) => x.id === a && x.available);
231
- let agent = has(s.agent) ? s.agent : S.agents.find((x) => x.available)?.id || s.agent;
232
- const models = S.agents.find((x) => x.id === agent)?.models || [];
234
+ let place = s.machine?.id || '';
235
+ const here = () => harnessesAt([place]).filter((x) => x.available);
236
+ const has = (a) => here().some((x) => x.id === a);
237
+ let agent = has(s.agent) ? s.agent : here()[0]?.id || s.agent;
238
+ const models = agentById(agent)?.models || [];
233
239
  let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
234
240
  let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
235
241
  let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || DEFAULT_MODE.id;
@@ -242,13 +248,14 @@ export async function rerun(id) {
242
248
  <textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
243
249
  ${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
244
250
  <div class="l-bar">
251
+ <button class="tog-btn where-btn" data-a="place" aria-haspopup="menu" ${S.machines.length ? '' : 'hidden'}></button>
245
252
  <button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
246
253
  <button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
247
254
  <span class="grow"></span>
248
255
  <button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
249
256
  </div>
250
257
  </div>
251
- <p class="hint">Starts a new session in <code>${esc(s.cwd)}</code>. “${esc(s.title)}” is kept as it is.</p>
258
+ <p class="hint rr-hint"></p>
252
259
  </div>`);
253
260
  const close = overlay(node);
254
261
  $('.close', node).onclick = close;
@@ -264,12 +271,37 @@ export async function rerun(id) {
264
271
  };
265
272
  const runnerBtn = $('[data-a="runner"]', node);
266
273
  const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
274
+ // Where it runs: where it ran, unless you pick another computer.
275
+ const placeBtn = $('[data-a="place"]', node);
276
+ const project = projectById(S.sessions.get(id)?.projectId);
277
+ const paintPlace = () => {
278
+ placeBtn.classList.toggle('on', !!place);
279
+ placeBtn.innerHTML = `${icon(place ? 'server' : 'laptop')}<span>${esc(placeName(place))}</span>${icon('chevron', 'chev')}`;
280
+ placeBtn.title = 'Run it on another computer';
281
+ const fresh = project && !pathAt(project, place);
282
+ $('.rr-hint', node).innerHTML = `Starts a new session in <code>${esc(project && !fresh ? pathAt(project, place) : s.cwd)}</code>${S.machines.length ? ` on <b>${esc(placeName(place))}</b>` : ''}${fresh ? `, in a checkout wisp clones there first` : ''}. “${esc(s.title)}” is kept as it is.`;
283
+ };
267
284
  paintMode();
268
285
  paintRunner();
286
+ paintPlace();
287
+ placeBtn.onclick = () =>
288
+ placeMenu(placeBtn, { title: 'Rerun on', current: place, from: s.machine?.id || '' }, (k) => {
289
+ place = k;
290
+ if (k && machineById(k)?.state === 'off') connectMachine(k);
291
+ // The harness has to be there too: keep it if it is, else the first one that is.
292
+ if (!has(agent) && here().length) {
293
+ agent = here()[0].id;
294
+ model = savedModel(agent);
295
+ effort = savedEffort(agent, model);
296
+ paintRunner();
297
+ }
298
+ paintPlace();
299
+ ta.focus();
300
+ });
269
301
  modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
270
302
  // Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
271
303
  runnerBtn.onclick = () =>
272
- runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
304
+ runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, places: [place], auto: true }, (a, m, e) => {
273
305
  [agent, model, effort] = [a, m, e];
274
306
  paintRunner();
275
307
  });
@@ -279,8 +311,8 @@ export async function rerun(id) {
279
311
  if (!prompt) return ta.focus();
280
312
  close();
281
313
  try {
282
- const body = { prompt, cwd: s.cwd, also: s.also?.map((a) => a.cwd), agent, mode, model, effort, attachments: files.map((f) => f.id) };
283
- const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
314
+ const body = { prompt, cwd: s.cwd, also: s.also?.map((a) => a.cwd), machine: place, agent, mode, model, effort, attachments: files.map((f) => f.id) };
315
+ const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort }, machine: place }, () => api('/sessions', { method: 'POST', body }));
284
316
  setFocus(next.id);
285
317
  } catch (err) {
286
318
  toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
@@ -0,0 +1,145 @@
1
+ // /btw side questions on a card: "/btw did you also update the docs?" in the composer asks without
2
+ // interrupting the run, and the answer shows on the turn it was asked during. The server answers
3
+ // each one with a read-only run of its own (server/btw.js).
4
+
5
+ import { fmtDuration } from '../blocks.js';
6
+ import { esc, md } from '../md.js';
7
+ import { mentionHtml } from '../mention-text.js';
8
+ import { api } from '../core/api.js';
9
+ import { $, $$, el, icon } from '../core/dom.js';
10
+ import { load, S, save } from '../core/state.js';
11
+ import { isBtw } from '../slash-text.js';
12
+ import { toastError } from '../ui/toast.js';
13
+ import { scheduleRefetch } from './feed.js';
14
+ import { agentName } from './runners.js';
15
+
16
+ const waiting = new Map(); // session id -> side questions sent, not yet confirmed by the server
17
+ const gone = new Set(); // ids dismissed here, until the server has dropped them too
18
+ let seq = 0;
19
+
20
+ // Optimistic: the question is on the card at once, waiting for its answer. If the server refuses
21
+ // it, it comes off again and restore() gives the text back.
22
+ export async function askBtw(id, question, { attachments = [], files = [], restore } = {}) {
23
+ const s = S.full.get(id);
24
+ if (!s) return;
25
+ retireTip(); // they know about it now
26
+ const pending = { id: `pending-${++seq}`, question, status: 'running', askedAt: Date.now(), agent: s.agent, attachments: files.filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
27
+ const mine = () => waiting.get(id) || [];
28
+ const unwait = () => (mine().length > 1 ? waiting.set(id, mine().filter((b) => b !== pending)) : waiting.delete(id));
29
+ waiting.set(id, [...mine(), pending]);
30
+ paintBtw(id);
31
+ try {
32
+ const entry = await api(`/sessions/${id}/btw`, { method: 'POST', body: { text: question, attachments } });
33
+ unwait();
34
+ // Its node takes the real id, so it stays put instead of animating in a second time.
35
+ const node = $(`.btw[data-id="${pending.id}"]`, S.nodes.get(id)?.card);
36
+ if (node) node.dataset.id = entry.id;
37
+ S.nodes.get(id)?.btwSeen?.add(entry.id);
38
+ const cur = S.full.get(id);
39
+ if (cur && !cur.turns.some((t) => t.btw?.some((b) => b.id === entry.id))) (cur.turns.at(-1).btw ||= []).push(entry);
40
+ paintBtw(id);
41
+ } catch (err) {
42
+ unwait();
43
+ paintBtw(id);
44
+ restore?.();
45
+ toastError(err);
46
+ }
47
+ }
48
+
49
+ // The tip in a working panel, so /btw gets found: until it's been used once, or hidden with its ×.
50
+ // Clicking it starts a /btw in the box below.
51
+ const TIP = 'wisp.btwTipDone';
52
+ export function btwTip(id) {
53
+ const s = S.full.get(id);
54
+ if (load(TIP, false) || !s || s.agent === 'wisp' || s.ask) return null;
55
+ const node = el(`
56
+ <span class="w-tip">
57
+ <button class="w-tip-go" title="Ask ${esc(agentName(s.agent))} something on the side. It keeps working, and the answer shows on this card"><span class="w-tip-k">Tip</span><code>/btw</code><span class="w-tip-t">asks without interrupting</span></button>
58
+ <button class="w-tip-x" title="Hide this tip" aria-label="Hide this tip">${icon('x')}</button>
59
+ </span>`);
60
+ $('.w-tip-go', node).onclick = () => {
61
+ const ta = $('.reply', S.nodes.get(id)?.card);
62
+ if (!ta) return;
63
+ if (!isBtw(ta.value)) ta.value = `/btw ${ta.value.trim()}`; // a draft already there becomes the question
64
+ ta.dispatchEvent(new Event('input'));
65
+ ta.focus();
66
+ ta.setSelectionRange(ta.value.length, ta.value.length);
67
+ };
68
+ $('.w-tip-x', node).onclick = retireTip;
69
+ return node;
70
+ }
71
+
72
+ function retireTip() {
73
+ save(TIP, true);
74
+ $$('.w-tip').forEach((n) => n.remove());
75
+ }
76
+
77
+ // Dismissing one that's still being answered stops its run. Gone at once; back if that fails.
78
+ async function dismissBtw(id, btwId) {
79
+ gone.add(btwId);
80
+ paintBtw(id);
81
+ try {
82
+ await api(`/sessions/${id}/btw?item=${encodeURIComponent(btwId)}`, { method: 'DELETE' });
83
+ for (const t of S.full.get(id)?.turns || []) if (t.btw) t.btw = t.btw.filter((b) => b.id !== btwId);
84
+ } catch (err) {
85
+ toastError(err);
86
+ scheduleRefetch(id);
87
+ }
88
+ gone.delete(btwId);
89
+ paintBtw(id);
90
+ }
91
+
92
+ // Fill each open turn's side-question list, keeping the nodes that haven't changed: only a newly
93
+ // asked one animates in, and an answer fades in where its "answering" line was.
94
+ export function paintBtw(id) {
95
+ const s = S.full.get(id);
96
+ const n = S.nodes.get(id);
97
+ if (!s || !n?.turnNodes) return;
98
+ const first = !n.btwSeen; // what's already there when the card opens doesn't animate
99
+ n.btwSeen ||= new Set();
100
+ const latest = s.turns.at(-1)?.id;
101
+ for (const t of s.turns) {
102
+ const list = n.turnNodes.get(t.id)?.node.querySelector(':scope > .btw-list');
103
+ if (!list) continue;
104
+ const items = [...(t.btw || []), ...(t.id === latest ? waiting.get(id) || [] : [])].filter((b) => !gone.has(b.id));
105
+ const ids = new Set(items.map((b) => b.id));
106
+ for (const c of [...list.children]) if (!ids.has(c.dataset.id)) c.remove();
107
+ items.forEach((b, i) => {
108
+ const key = `${b.status}|${b.answer || b.error || ''}`;
109
+ let node = [...list.children].find((c) => c.dataset.id === b.id);
110
+ if (!node || node.dataset.key !== key) {
111
+ const next = item(s, b);
112
+ next.dataset.key = key;
113
+ if (!node && !first && !n.btwSeen.has(b.id)) next.classList.add('new');
114
+ if (node?.dataset.key.startsWith('running|') && b.status !== 'running') next.classList.add('landed');
115
+ $('.btw-x', next).onclick = () => {
116
+ dismissBtw(id, b.id);
117
+ };
118
+ if (node) node.replaceWith(next);
119
+ node = next;
120
+ }
121
+ n.btwSeen.add(b.id);
122
+ if (list.children[i] !== node) list.insertBefore(node, list.children[i] || null);
123
+ });
124
+ }
125
+ }
126
+
127
+ function item(s, b) {
128
+ const who = agentName(b.agent || s.agent);
129
+ const at = new Date(b.askedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
130
+ const how = b.status !== 'done' ? '' : b.forked ? 'on a copy of the conversation' : 'from wisp’s record of the task';
131
+ const tip = [`Side question, asked ${at}`, b.answeredAt && `answered in ${fmtDuration(b.answeredAt - b.askedAt)}`, how].filter(Boolean).join(' · ');
132
+ const body =
133
+ b.status === 'running'
134
+ ? `<div class="btw-wait"><span class="btw-dots"><i></i><i></i><i></i></span>${esc(who)} is answering on the side · the run carries on<span class="elapsed" data-start="${b.askedAt}">${fmtDuration(Date.now() - b.askedAt)}</span></div>`
135
+ : b.status === 'error'
136
+ ? `<div class="btw-err">${icon('warn')}<span>${esc(b.error || 'No answer.')}</span></div>`
137
+ : `<div class="btw-a prose">${md(b.answer || '')}</div>`;
138
+ return el(`
139
+ <div class="btw st-${b.status}" data-id="${esc(b.id)}">
140
+ <div class="btw-q"><span class="btw-tag" title="${esc(tip)}">${icon('ask')}btw</span><span class="btw-text">${mentionHtml(esc(b.question), { link: false })}</span></div>
141
+ ${b.attachments?.length ? `<div class="btw-atts">${b.attachments.map((a) => `<span class="att-pill">${icon('file')}${esc(a.name)}</span>`).join('')}</div>` : ''}
142
+ ${body}
143
+ <button class="icon-btn btw-x" title="${b.status === 'running' ? 'Cancel this side question' : 'Dismiss'}">${icon('x')}</button>
144
+ </div>`);
145
+ }
@@ -33,7 +33,7 @@ const tilde = (p) => (S.config.home && p.startsWith(S.config.home) ? `~${p.slice
33
33
  // goes back to what it had picked.
34
34
  export function startClone(url, where = null) {
35
35
  const k = where || '';
36
- const prev = launch.picks[k];
36
+ const prev = launch.picks[''];
37
37
  const job = { id: `clone-${++seq}`, url, where: where || null, name: repoName(url), label: repoLabel(url) };
38
38
  clones.push(job);
39
39
  // A feed filtered to another project would pull the launcher back to that project.
@@ -43,13 +43,14 @@ export function startClone(url, where = null) {
43
43
  renderFeed();
44
44
  renderProjects();
45
45
  }
46
- launch.picks[k] = job.id;
46
+ launch.picks[''] = job.id;
47
+ delete launch.also[''];
48
+ launch.project = job.id;
47
49
  if (!launch.where.includes(k)) launch.where = [k];
48
- if (k === launch.where[0]) launch.project = job.id;
49
50
  const repick = (id) => {
50
- if (launch.picks[k] !== job.id) return; // picked something else meanwhile
51
- launch.picks[k] = id;
52
- if (k === launch.where[0]) launch.project = id;
51
+ if (launch.picks[''] !== job.id) return; // picked something else meanwhile
52
+ launch.picks[''] = id;
53
+ launch.project = id;
53
54
  saveLaunch();
54
55
  };
55
56
  const on = where ? ` on ${esc(machineById(where)?.name || 'the machine')}` : '';
@@ -20,7 +20,8 @@ import { openProjects, renderProjects } from './projects.js';
20
20
  import { agentName } from './runners.js';
21
21
  import { openSettings } from './settings.js';
22
22
  import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
23
- import { replay } from './turns.js';
23
+ import { transcriptOn } from './transcript.js';
24
+ import { replay, toggleTranscript } from './turns.js';
24
25
  import { openUsage } from './usage.js';
25
26
 
26
27
  const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
@@ -55,6 +56,7 @@ function paletteItems() {
55
56
  if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
56
57
  items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
57
58
  items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
59
+ items.push({ group: g, label: transcriptOn(id) ? 'Show the presentation' : 'Show the full transcript', sub, icon: 'transcript', kbd: 'V', keywords: 'transcript log messages steps tool calls verbose activity history', run: () => withCard(id, () => toggleTranscript(id)) });
58
60
  items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
59
61
  items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
60
62
  }
@@ -1,22 +1,28 @@
1
- // The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups.
1
+ // The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups, and
2
+ // side questions (/btw), which don't interrupt the run.
2
3
 
3
4
  import { esc } from '../md.js';
4
5
  import { mentionHtml } from '../mention-text.js';
6
+ import { btwQuestion, isBtw } from '../slash-text.js';
5
7
  import { api } from '../core/api.js';
6
8
  import { $, el, icon } from '../core/dom.js';
7
- import { S } from '../core/state.js';
9
+ import { S, saveOpen } from '../core/state.js';
8
10
  import { clampToViewport, popover } from '../ui/popover.js';
9
11
  import { toast, toastError } from '../ui/toast.js';
10
12
  import { toggleDone } from './actions.js';
11
13
  import { acceptFiles, attachTray, autosize } from './attachments.js';
12
- import { renderFeed, scheduleRefetch, setFocus } from './feed.js';
14
+ import { askBtw } from './btw.js';
15
+ import { expand, renderFeed, scheduleRefetch, setFocus } from './feed.js';
13
16
  import { feedbackCommand, isFeedback } from './feedback.js';
14
17
  import { mentionBox } from './mentions.js';
15
- import { placeName } from './places.js';
18
+ import { connectMachine } from './machines.js';
19
+ import { placeMenu } from './place-menu.js';
20
+ import { machineById, placeName } from './places.js';
16
21
  import { runnerPicker } from './runner-picker.js';
17
22
  import { agentById, agentName, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
18
23
  import { feedAnchor, keepInPlace, watching } from './scroll.js';
19
24
  import { shipDialog } from './ship.js';
25
+ import { slashMenu } from './slash.js';
20
26
  import { renderBody } from './turns.js';
21
27
 
22
28
  export function draftInto(id, text) {
@@ -39,6 +45,7 @@ export function renderDock(id) {
39
45
  <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
40
46
  <button class="mode-btn c-mode" aria-haspopup="menu"></button>
41
47
  <button class="c-runner" aria-haspopup="dialog"></button>
48
+ <button class="c-place" aria-haspopup="menu" hidden></button>
42
49
  <span class="c-hint"></span>
43
50
  <button class="ghost sm c-ship" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
44
51
  <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
@@ -50,6 +57,7 @@ export function renderDock(id) {
50
57
  n.tray = attachTray();
51
58
  comp.prepend(n.tray.node);
52
59
  const ta = mentionBox($('.reply', comp));
60
+ slashMenu(ta, comp);
53
61
  autosize(ta, 260);
54
62
  acceptFiles(comp, ta, n.tray);
55
63
  $('.c-attach', comp).onclick = n.tray.pick;
@@ -62,8 +70,15 @@ export function renderDock(id) {
62
70
  runnerBtn.onclick = () => {
63
71
  pickReplyRunner(id, runnerBtn);
64
72
  };
73
+ const placeBtn = $('.c-place', comp);
74
+ placeBtn.onclick = () => {
75
+ pickReplyPlace(id, placeBtn);
76
+ };
65
77
  ta.value = S.drafts.get(id) || '';
66
- ta.addEventListener('input', () => S.drafts.set(id, ta.value));
78
+ ta.addEventListener('input', () => {
79
+ S.drafts.set(id, ta.value);
80
+ paintSide(id);
81
+ });
67
82
  ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
68
83
  ta.addEventListener('keydown', (e) => {
69
84
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
@@ -122,7 +137,7 @@ function pickReplyRunner(id, btn) {
122
137
  // The session's own harness offers its own model first; other harnesses their remembered one.
123
138
  const model = (a) => (a === s.agent && (agentById(a)?.models || []).some((m) => m.id === s.model) ? s.model : savedModel(a));
124
139
  const effort = (a, m) => (a === s.agent && m === s.model && effortsFor(a, m).includes(s.effort) ? s.effort : savedEffort(a, m));
125
- runnerPicker(btn, { agent: cur.agent, model: cur.model, effort: cur.effort, savedModel: model, savedEffort: effort, places: [s.machine?.id || ''] }, (agent, m, e) => {
140
+ runnerPicker(btn, { agent: cur.agent, model: cur.model, effort: cur.effort, savedModel: model, savedEffort: effort, places: [replyPlace(id)] }, (agent, m, e) => {
126
141
  setReplyRunner(id, { agent, model: m, effort: e });
127
142
  });
128
143
  }
@@ -138,13 +153,68 @@ function paintReplyRunner(id, btn) {
138
153
  const label = run.agent === s?.agent ? model : `${harness} · ${model}`;
139
154
  btn.innerHTML = `<span class="rb-logo">${harnessLogo(run.agent)}</span>${changed ? `<span class="cr-label">${esc(label)}</span>${run.effort ? `<span class="rb-e">${esc(effortLabel(run.effort))}</span>` : ''}` : ''}`;
140
155
  const now = runnerTip(s?.agent, s?.model, s?.effort);
141
- const where = s?.machine ? ` (on ${placeName(s.machine.id)})` : '';
156
+ const where = s?.machine || S.replyPlaces.has(id) ? ` (on ${placeName(replyPlace(id))})` : '';
142
157
  btn.title = !changed
143
158
  ? `${now}\nPick another harness or model for your next follow-up${where}`
144
159
  : run.agent !== s.agent
145
160
  ? `${runnerTip(run.agent, run.model, run.effort)}\nYour next follow-up moves this task from ${agentName(s.agent)} to ${harness}, with the conversation so far. Click to change`
146
161
  : `${runnerTip(run.agent, run.model, run.effort)}\nYour next follow-up switches from ${now}. Same conversation. Click to change`;
147
- btn.hidden = !s || s.agent === 'wisp' || !!s.parentId || !harnessesAt([s.machine?.id || '']).some((a) => a.available);
162
+ btn.hidden = !s || s.agent === 'wisp' || !!s.parentId || !harnessesAt([replyPlace(id)]).some((a) => a.available);
163
+ }
164
+
165
+ // Where the next follow-up runs: where the task is, until the user picks another computer here.
166
+ // Then the follow-up moves the task there, its work and history with it (server/services/move.js).
167
+ const placeNow = (s) => s?.machine?.id || '';
168
+ const replyPlace = (id) => (S.replyPlaces.has(id) ? S.replyPlaces.get(id) : placeNow(S.full.get(id)));
169
+
170
+ function setReplyPlace(id, k) {
171
+ const s = S.full.get(id);
172
+ if (!s) return;
173
+ if (k === placeNow(s)) S.replyPlaces.delete(id);
174
+ else S.replyPlaces.set(id, k);
175
+ if (k && machineById(k)?.state === 'off') connectMachine(k);
176
+ // The harness it runs on has to be there: keep it if it is, else the first one that is.
177
+ const run = replyRunner(id);
178
+ const there = harnessesAt([k]).filter((a) => a.available);
179
+ if (there.length && !there.some((a) => a.id === run.agent)) {
180
+ const a = there[0].id;
181
+ const m = savedModel(a);
182
+ setReplyRunner(id, { agent: a, model: m, effort: savedEffort(a, m) });
183
+ }
184
+ updateDock(id);
185
+ }
186
+
187
+ function pickReplyPlace(id, btn) {
188
+ const s = S.full.get(id);
189
+ if (!s) return;
190
+ const busy = s.state === 'running' || s.state === 'waiting';
191
+ placeMenu(
192
+ btn,
193
+ {
194
+ title: 'Next follow-up runs on',
195
+ current: replyPlace(id),
196
+ from: placeNow(s),
197
+ note: 'Another computer takes the task with it: its branch, uncommitted changes and history. wisp clones the project there if it has to.',
198
+ locked: busy ? 'Stop the task or let it finish to move it to another computer.' : '',
199
+ },
200
+ (k) => {
201
+ setReplyPlace(id, k);
202
+ $('.reply', S.nodes.get(id)?.card)?.focus();
203
+ },
204
+ );
205
+ }
206
+
207
+ function paintReplyPlace(id, btn) {
208
+ const s = S.full.get(id);
209
+ const k = replyPlace(id);
210
+ const changed = !!s && S.replyPlaces.has(id);
211
+ btn.className = `c-place${changed ? ' changed' : ''}`;
212
+ btn.innerHTML = `${icon(k ? 'server' : 'laptop')}${changed ? `<span class="cr-label">${esc(placeName(k))}</span>` : ''}`;
213
+ btn.title = changed
214
+ ? `Your next follow-up moves this task from ${placeName(placeNow(s))} to ${placeName(k)}, with its work and history. Click to change`
215
+ : `Runs on ${placeName(k)}. Pick another computer for your next follow-up`;
216
+ // Only with machines to move to; subagents go where their lead is, the tour nowhere.
217
+ btn.hidden = !s || !S.machines.length || s.agent === 'wisp' || !!s.parentId;
148
218
  }
149
219
 
150
220
  export function updateDock(id) {
@@ -164,16 +234,19 @@ export function updateDock(id) {
164
234
  : `Answer ${name}…`
165
235
  : s.state === 'running'
166
236
  ? queueing()
167
- ? `Queue a follow-up — sent when ${name} finishes`
168
- : `Steer ${name} — interrupts and sends now`
169
- : s.state === 'done'
170
- ? next !== name
171
- ? `Follow up with ${next} to reopen…`
172
- : 'Follow up to reopen…'
173
- : `Follow up with ${next}…`;
174
- $('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
237
+ ? `Queue a follow-up — sent when ${name} finishes. /btw asks now`
238
+ : `Steer ${name} — interrupts and sends now. /btw asks without interrupting`
239
+ : S.replyPlaces.has(id)
240
+ ? `Follow up on ${replyPlace(id) ? placeName(replyPlace(id)) : 'this computer'} with ${next}…`
241
+ : s.state === 'done'
242
+ ? next !== name
243
+ ? `Follow up with ${next} to reopen…`
244
+ : 'Follow up to reopen…'
245
+ : `Follow up with ${next}…`;
246
+ paintSide(id);
175
247
  paintReplyMode(id, $('.c-mode', n.card));
176
248
  paintReplyRunner(id, $('.c-runner', n.card));
249
+ paintReplyPlace(id, $('.c-place', n.card));
177
250
  renderQueued(s, $('.c-queued', n.card), name);
178
251
  const q = $('.c-queue', n.card);
179
252
  q.hidden = tour || s.state !== 'running';
@@ -183,6 +256,23 @@ export function updateDock(id) {
183
256
  : `Sending now: follow-ups interrupt ${name}. Click to queue them instead.`;
184
257
  }
185
258
 
259
+ // "/btw …" in the box: a side question, so the composer says it won't interrupt anything, and the
260
+ // mode and harness pickers (which are for the next follow-up) step back.
261
+ function paintSide(id) {
262
+ const s = S.full.get(id);
263
+ const comp = $('.composer', S.nodes.get(id)?.card);
264
+ if (!s || !comp) return;
265
+ const side = isBtw($('.reply', comp).value);
266
+ const live = s.state === 'running' || s.state === 'waiting';
267
+ comp.classList.toggle('is-btw', side);
268
+ $('.send', comp).title = side ? 'Ask on the side (Enter): nothing is interrupted' : 'Send (Enter · Shift+Enter for newline)';
269
+ $('.c-hint', comp).innerHTML = side
270
+ ? `<span class="c-side">Side question</span> · ${live ? `${esc(agentName(s.agent))} keeps working` : 'no new turn'}`
271
+ : s.queue?.length
272
+ ? `${s.queue.length} queued`
273
+ : '';
274
+ }
275
+
186
276
  // Follow-ups waiting their turn, shown above the composer so it's clear what goes out next.
187
277
  function renderQueued(s, list, name) {
188
278
  const key = (s.queue || []).map((q) => `${q.id}${q.run ? `:${q.run.agent}/${q.run.model}` : ''}`).join();
@@ -245,6 +335,21 @@ export async function sendReply(id, text) {
245
335
  text = (text || '').trim();
246
336
  if (!s || !text) return;
247
337
  const ta = n && $('.reply', n.card);
338
+ // A side question: answered next to the run, so nothing is interrupted, queued or switched.
339
+ if (isBtw(text)) {
340
+ const question = btwQuestion(text);
341
+ if (!question) return toast('Type your question after /btw, like “/btw did you update the docs too?”');
342
+ if (n?.tray?.busy) toast('Waiting for uploads…');
343
+ const attachments = n?.tray ? await n.tray.ids() : [];
344
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
345
+ S.drafts.delete(id);
346
+ const files = n?.tray?.take() || [];
347
+ const restore = () => {
348
+ if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
349
+ n?.tray?.restore(files);
350
+ };
351
+ return askBtw(id, question, { attachments, files, restore });
352
+ }
248
353
  if (isFeedback(text)) {
249
354
  if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
250
355
  S.drafts.delete(id);
@@ -254,6 +359,8 @@ export async function sendReply(id, text) {
254
359
  const attachments = n?.tray ? await n.tray.ids() : [];
255
360
  const mode = S.replyModes.get(id);
256
361
  const run = S.replyRunners.get(id);
362
+ const place = S.replyPlaces.has(id) ? S.replyPlaces.get(id) : undefined;
363
+ if (place !== undefined) return moveWithReply(id, text, { attachments, mode, run, place });
257
364
  // An answer goes to the run that asked; a new mode or harness needs a new run, so that's a follow-up.
258
365
  const answering = !!s.ask && !attachments.length && !mode && !run;
259
366
  // Optimistic: the composer empties and the card goes to Working before the server answers
@@ -282,10 +389,47 @@ export async function sendReply(id, text) {
282
389
  }
283
390
  }
284
391
 
392
+ // A follow-up on another computer: the task moves there and carries on as a new session (the old
393
+ // one is archived). The card says it's moving at once; the new card takes its place, open.
394
+ async function moveWithReply(id, text, { attachments, mode, run, place }) {
395
+ const n = S.nodes.get(id);
396
+ const ta = n && $('.reply', n.card);
397
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
398
+ S.drafts.delete(id);
399
+ for (const m of [S.replyModes, S.replyRunners, S.replyPlaces]) m.delete(id);
400
+ const files = n?.tray?.take();
401
+ const undo = startOptimistic(id, text, files, { mode, run, live: `Moving to ${placeName(place)}` });
402
+ try {
403
+ const r = await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, mode, ...run, machine: place } });
404
+ const next = r.session;
405
+ S.sessions.set(next.id, next);
406
+ const old = S.sessions.get(id);
407
+ if (old) S.sessions.set(id, { ...old, state: 'review', live: '', archived: true, movedTo: { id: next.id, place: placeName(place) } });
408
+ S.full.delete(id);
409
+ const wasOpen = S.open.delete(id);
410
+ saveOpen();
411
+ keepInPlace(feedAnchor(), renderFeed);
412
+ if (wasOpen) expand(next.id);
413
+ setFocus(next.id);
414
+ toast(`Moved to <b>${esc(place ? placeName(place) : 'this computer')}</b> with its work and history`);
415
+ } catch (err) {
416
+ undo?.();
417
+ if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
418
+ if (mode && !S.replyModes.has(id)) setReplyMode(id, mode);
419
+ if (run && !S.replyRunners.has(id)) setReplyRunner(id, run);
420
+ if (!S.replyPlaces.has(id)) {
421
+ S.replyPlaces.set(id, place);
422
+ updateDock(id);
423
+ }
424
+ n?.tray?.restore(files);
425
+ toastError(err);
426
+ }
427
+ }
428
+
285
429
  // Show a follow-up as started right away: the card moves to Working with the prompt as a live
286
430
  // turn. Only when it will start now, not when it queues or interrupts a run already working.
287
431
  // Returns an undo for a failed send, or null when nothing changed.
288
- export function startOptimistic(id, text, files, { answering, mode, run }) {
432
+ export function startOptimistic(id, text, files, { answering, mode, run, live: label }) {
289
433
  const full = S.full.get(id);
290
434
  const sum = S.sessions.get(id);
291
435
  if (!full || !sum) return null;
@@ -296,7 +440,7 @@ export function startOptimistic(id, text, files, { answering, mode, run }) {
296
440
  if (mode) turn.mode = mode;
297
441
  if (run) Object.assign(turn, run);
298
442
  const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn], ...(mode && { mode }), ...run };
299
- const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : run && run.agent !== full.agent ? `Handing over to ${agentName(run.agent)}` : 'Starting up', updatedAt: now, ...(mode && { mode }), ...run };
443
+ const nextSum = { ...sum, state: 'running', live: label || (answering ? 'Picking up your answer' : run && run.agent !== full.agent ? `Handing over to ${agentName(run.agent)}` : 'Starting up'), updatedAt: now, ...(mode && { mode }), ...run };
300
444
  const apply = (f, sm) => {
301
445
  S.full.set(id, f);
302
446
  S.sessions.set(id, sm);
@@ -359,7 +359,7 @@ export function collapse(id) {
359
359
  renderFeedHead(groups().needs, groups().working);
360
360
  }
361
361
 
362
- async function fetchFull(id) {
362
+ export async function fetchFull(id) {
363
363
  const s = await api(`/sessions/${id}`).catch(() => null);
364
364
  if (!s) {
365
365
  S.open.delete(id);
@@ -10,7 +10,7 @@ import { collapse, collapseAll, expand, feedIds, setFocus, toggle } from './feed
10
10
  import { focusLauncher } from './launcher.js';
11
11
  import { backToTop } from './scroll.js';
12
12
  import { shipDialog, shippable } from './ship.js';
13
- import { replay } from './turns.js';
13
+ import { replay, toggleTranscript } from './turns.js';
14
14
 
15
15
  export function onKey(e) {
16
16
  // ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
@@ -69,6 +69,7 @@ export function onKey(e) {
69
69
  else if (k === 'r' && e.shiftKey && id) rerun(id);
70
70
  else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
71
71
  else if (k === 'r' && id) replay(id);
72
+ else if (k === 'v' && id) toggleTranscript(id);
72
73
  else if (k === '/' && id) {
73
74
  e.preventDefault();
74
75
  const focusReply = () => $('.reply', S.nodes.get(id)?.card)?.focus();