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.
Files changed (122) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
package/web/js/blocks.js CHANGED
@@ -788,6 +788,52 @@ R.question = (b, ctx) => {
788
788
  return node;
789
789
  };
790
790
 
791
+ // What the turn's permissions refused. On the latest turn: Allow & retry (exactly these where the
792
+ // harness can scope a grant, else one follow-up in Full auto) or Deny.
793
+ R.blocked = (b, ctx) => {
794
+ const items = (b.items || []).filter((i) => i && typeof i === 'object');
795
+ const t = ctx.turn || {};
796
+ const scoped = !!t.blocked?.length && t.blocked.every((i) => i.rule !== undefined);
797
+ const open = ctx.isLatest && !t.approval && !!ctx.approve && ctx.session?.state !== 'running';
798
+ const state = (a) => (a === 'allowed' ? `${icon('check')}Allowed and retried` : a === 'denied' ? `${icon('x')}Denied` : '');
799
+ const node = el(`
800
+ <div class="b-blocked ${open ? '' : 'resolved'}">
801
+ <div class="q-head">${icon('lock')}<span>Blocked by permissions</span>${t.approval ? `<span class="bl-state">${state(t.approval)}</span>` : ''}</div>
802
+ <ul class="bl-items">${items
803
+ .map((i) => `<li><span class="bl-tool">${esc(i.tool || 'tool')}</span>${i.detail ? `<code>${esc(i.detail)}</code>` : ''}${i.why ? `<small>${inlineMd(i.why)}</small>` : ''}</li>`)
804
+ .join('')}</ul>
805
+ ${open ? `<div class="bl-foot">
806
+ <button class="primary sm bl-allow">${icon('check')}Allow &amp; retry</button>
807
+ <button class="ghost sm bl-deny">Deny</button>
808
+ <span class="bl-note">${scoped ? 'Allows exactly these, for the retry only.' : 'This harness can’t allow single actions, so the retry runs in Full auto (that follow-up only).'}</span>
809
+ </div>` : ''}
810
+ </div>`);
811
+ if (!open) return node;
812
+ const decide = async (allow) => {
813
+ // Instant: the card settles at once and goes back if the server says no.
814
+ const foot = node.querySelector('.bl-foot');
815
+ const head = node.querySelector('.q-head');
816
+ const tag = el(`<span class="bl-state">${state(allow ? 'allowed' : 'denied')}</span>`);
817
+ node.classList.add('resolved');
818
+ foot.hidden = true;
819
+ head.append(tag);
820
+ try {
821
+ await ctx.approve(allow);
822
+ } catch {
823
+ node.classList.remove('resolved');
824
+ foot.hidden = false;
825
+ tag.remove();
826
+ }
827
+ };
828
+ node.querySelector('.bl-allow').onclick = () => {
829
+ decide(true);
830
+ };
831
+ node.querySelector('.bl-deny').onclick = () => {
832
+ decide(false);
833
+ };
834
+ return node;
835
+ };
836
+
791
837
  R.actions = (b, ctx) => {
792
838
  const items = (b.items || []).map((it, i) => (typeof it === 'string' ? { label: it, command: it, _i: i } : { ...it, _i: i }));
793
839
  const node = el(`
@@ -0,0 +1,25 @@
1
+ // The wisp HTTP API (server/routes/). A failed request throws with the server's message.
2
+
3
+ export async function api(path, { method = 'GET', body } = {}) {
4
+ const r = await fetch(`/api${path}`, {
5
+ method,
6
+ // The server only accepts JSON on non-GET requests (CSRF guard), even body-less DELETEs.
7
+ headers: method !== 'GET' ? { 'content-type': 'application/json' } : {},
8
+ body: body !== undefined ? JSON.stringify(body) : undefined,
9
+ });
10
+ const j = await r.json().catch(() => ({}));
11
+ if (!r.ok) throw new Error(j.error || r.statusText);
12
+ return j;
13
+ }
14
+
15
+ // An attachment: the raw bytes, with its name and type in the query. Resolves to { id }.
16
+ export async function upload(file, { name, type }) {
17
+ const r = await fetch(`/api/uploads?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`, {
18
+ method: 'POST',
19
+ headers: { 'content-type': 'application/octet-stream' },
20
+ body: file,
21
+ });
22
+ const j = await r.json();
23
+ if (!r.ok) throw new Error(j.error);
24
+ return j;
25
+ }
@@ -0,0 +1,28 @@
1
+ // A small event bus. Live server events (features/live.js) are published here, and open views
2
+ // subscribe instead of being looked up in the DOM and poked. A subscription tied to a node ends
3
+ // once that node has left the page, so a closed dialog stops listening.
4
+ //
5
+ // Events: agents, config, install, machines, preferences, remote, router.
6
+
7
+ const subs = new Map(); // type -> Set<{ fn, node }>
8
+
9
+ export function on(type, fn, { node } = {}) {
10
+ const sub = { fn, node };
11
+ if (!subs.has(type)) subs.set(type, new Set());
12
+ subs.get(type).add(sub);
13
+ return () => subs.get(type).delete(sub);
14
+ }
15
+
16
+ export function emit(type, data) {
17
+ const set = subs.get(type);
18
+ if (!set) return;
19
+ for (const sub of [...set]) {
20
+ if (sub.node && !sub.node.isConnected) set.delete(sub);
21
+ else sub.fn(data);
22
+ }
23
+ }
24
+
25
+ // Forget subscriptions whose node is gone (called when a dialog closes).
26
+ export function sweep() {
27
+ for (const set of subs.values()) for (const sub of set) if (sub.node && !sub.node.isConnected) set.delete(sub);
28
+ }
@@ -0,0 +1,7 @@
1
+ // DOM helpers. el (an element from an HTML string) and icon (a sprite <svg>) live with the block
2
+ // renderers, which use them too.
3
+
4
+ export { el, icon } from '../blocks.js';
5
+
6
+ export const $ = (s, root = document) => root.querySelector(s);
7
+ export const $$ = (s, root = document) => [...root.querySelectorAll(s)];
@@ -0,0 +1,27 @@
1
+ // Small display formatters shared across the page.
2
+
3
+ export const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
4
+
5
+ export function ago(t) {
6
+ const s = (Date.now() - t) / 1000;
7
+ if (s < 45) return 'now';
8
+ if (s < 3600) return `${Math.round(s / 60)}m`;
9
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
10
+ return `${Math.round(s / 86400)}d`;
11
+ }
12
+
13
+ export function fmtSize(n) {
14
+ if (n < 1024) return `${n} B`;
15
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
16
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
17
+ }
18
+
19
+ export const whenLabel = (t) => {
20
+ if (!t) return '';
21
+ const d = new Date(t);
22
+ const today = new Date().toDateString() === d.toDateString();
23
+ return d.toLocaleString([], today ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
24
+ };
25
+
26
+ export const money = (v) => (v >= 100 ? `$${Math.round(v)}` : `$${(v || 0).toFixed(2)}`);
27
+ export const pct = (v) => (v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`);
@@ -0,0 +1,43 @@
1
+ // What the page knows: sessions and their cards, config, harnesses and machines, plus the
2
+ // launcher's picks. UI state that should survive a reload goes to localStorage as wisp.*.
3
+
4
+ export function load(k, d) {
5
+ try {
6
+ return JSON.parse(localStorage.getItem(k)) ?? d;
7
+ } catch {
8
+ return d;
9
+ }
10
+ }
11
+ export function save(k, v) {
12
+ try {
13
+ localStorage.setItem(k, JSON.stringify(v));
14
+ } catch {}
15
+ }
16
+
17
+ export const S = {
18
+ sessions: new Map(), // id -> summary
19
+ full: new Map(), // id -> full session, for expanded items
20
+ open: new Set(load('wisp.open', [])), // expanded item ids
21
+ nodes: new Map(), // id -> { card, head, body, turnNodes, tray, showAll, unfolded, latestTurn }
22
+ tasks: new Map(), // compare task key -> { card, head, runs, lead }
23
+ focus: null, // id of the keyboard-focused item
24
+ config: { defaults: {}, projects: [] },
25
+ agents: [],
26
+ machines: [], // other computers over SSH: GET /api/machines, kept fresh by the 'machines' event
27
+ filter: load('wisp.filter', 'all'),
28
+ showDone: load('wisp.showDone', false),
29
+ showArchived: false,
30
+ drafts: new Map(),
31
+ replyModes: new Map(), // session id → the mode its next follow-up switches to
32
+ replyRunners: new Map(), // session id → { agent, model, effort } its next follow-up switches to
33
+ };
34
+ export const saveOpen = () => save('wisp.open', [...S.open]);
35
+
36
+ // Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
37
+ // each in the project picked for it (launch.picks[place]), and show up as one task.
38
+ export const launch = {
39
+ project: load('wisp.launchProject', null), // the project picked in the first place
40
+ where: load('wisp.launchWhere', ['']),
41
+ picks: load('wisp.launchPicks', {}),
42
+ };
43
+ export const saveLaunch = () => (save('wisp.launchWhere', launch.where), save('wisp.launchPicks', launch.picks), save('wisp.launchProject', launch.project));
@@ -0,0 +1,344 @@
1
+ // What you can do to a session: done, archive, delete, rename, rerun, revert, take over, open files,
2
+ // and the menus that offer it. Local changes apply at once and roll back if the server refuses.
3
+
4
+ import { copy } from '../blocks.js';
5
+ import { esc } from '../md.js';
6
+ import { api } from '../core/api.js';
7
+ import { $, el, icon } from '../core/dom.js';
8
+ import { S } from '../core/state.js';
9
+ import { keyHint } from '../ui/kbd.js';
10
+ import { overlay } from '../ui/overlay.js';
11
+ import { popover } from '../ui/popover.js';
12
+ import { toast, toastError, tryApi } from '../ui/toast.js';
13
+ import { autosize } from './attachments.js';
14
+ import { saveAsRecipe } from './automations.js';
15
+ import { compareView } from './compare.js';
16
+ import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
17
+ import { launchBehind } from './launcher.js';
18
+ import { copyTaskId, mentionBox } from './mentions.js';
19
+ import { runnerPicker } from './runner-picker.js';
20
+ import { agentName, DEFAULT_MODE, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
21
+ import { feedAnchor, keepInPlace } from './scroll.js';
22
+ import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
23
+
24
+ export async function toggleDone(id) {
25
+ const s = S.sessions.get(id);
26
+ if (!s) return;
27
+ if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
28
+ const next = s.state === 'done' ? 'review' : 'done';
29
+ // Optimistic: the card moves now; a failed save puts it back.
30
+ move(id, { ...s, state: next }, next === 'done');
31
+ if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
32
+ try {
33
+ await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
34
+ } catch (err) {
35
+ revertSummary(id, s);
36
+ toastError(err);
37
+ }
38
+ }
39
+
40
+ // Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
41
+ // hands focus to the next card; coming back just re-renders around the reader.
42
+ function move(id, next, leaving) {
43
+ if (leaving) return handOff(id)(() => S.sessions.set(id, next));
44
+ S.sessions.set(id, next);
45
+ keepInPlace(feedAnchor(id), renderFeed);
46
+ }
47
+
48
+ // Put back a summary changed optimistically, unless a live update has replaced it since.
49
+ function revertSummary(id, prev) {
50
+ const cur = S.sessions.get(id);
51
+ if (cur && cur.updatedAt > prev.updatedAt) return;
52
+ S.sessions.set(id, prev);
53
+ keepInPlace(feedAnchor(id), renderFeed);
54
+ }
55
+
56
+ // Every finished run of a compare task at once, with one toast to undo them all.
57
+ export async function taskDone(ids) {
58
+ if (!ids.length) return;
59
+ const card = S.nodes.get(ids[0])?.card.closest('.task-item');
60
+ const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
61
+ const set = (state) => {
62
+ const prev = ids.map((id) => S.sessions.get(id));
63
+ for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
64
+ renderFeed();
65
+ Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
66
+ prev.forEach((p, i) => p && revertSummary(ids[i], p));
67
+ toastError(err);
68
+ });
69
+ };
70
+ set('done');
71
+ if (next && S.nodes.has(next)) setFocus(next);
72
+ toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
73
+ }
74
+
75
+ export function revertFile(id, turnId, file, restore = false) {
76
+ return tryApi(async () => {
77
+ await api(`/sessions/${id}/revert`, { method: 'POST', body: { turnId, file, restore } });
78
+ toast(restore ? `Re-applied ${esc(file)}` : `Reverted ${esc(file)}`, {
79
+ action: { label: 'Undo', run: () => revertFile(id, turnId, file, !restore) },
80
+ });
81
+ });
82
+ }
83
+
84
+ export async function rollbackTurn(id, turnId, restore = false) {
85
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
86
+ const turn = s?.turns?.find((t) => t.id === turnId) || s?.turns?.at(-1);
87
+ if (!turn) return;
88
+ await tryApi(async () => {
89
+ const r = await api(`/sessions/${id}/rollback`, { method: 'POST', body: { turnId: turn.id, restore } });
90
+ toast(
91
+ restore
92
+ ? `Re-applied turn changes`
93
+ : `Restored working tree to before this turn${r.head ? ` (${esc(r.head.slice(0, 7))})` : ''}`,
94
+ { action: { label: 'Undo', run: () => rollbackTurn(id, turn.id, !restore) } },
95
+ );
96
+ });
97
+ }
98
+
99
+ // The server keeps the result on the turn under `key` (turn.actionRuns), where the block that
100
+ // ran it looks for it when the card renders again.
101
+ export async function execAction(id, turnId, command, key) {
102
+ return (await tryApi(() => api(`/sessions/${id}/exec`, { method: 'POST', body: { command, turnId, key } }))) ?? null;
103
+ }
104
+
105
+ export async function takeOver(id) {
106
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
107
+ if (!s?.agentSessionId) return;
108
+ if (s.state === 'running' && !confirm(`${agentName(s.agent)} is still running. Stop it and take over in your terminal?`)) return;
109
+ await tryApi(async () => {
110
+ const { command } = await api(`/sessions/${id}/takeover`, { method: 'POST', body: {} });
111
+ toast(`${icon('terminal')} Opened in your terminal`, { action: { label: 'Copy command', run: () => copy(command) } });
112
+ });
113
+ }
114
+
115
+ export function rename(id) {
116
+ const n = S.nodes.get(id);
117
+ const title = n && $('.it-title', n.head);
118
+ if (!title) return;
119
+ title.contentEditable = 'true';
120
+ title.focus();
121
+ document.getSelection().selectAllChildren(title);
122
+ const done = (commit) => {
123
+ title.contentEditable = 'false';
124
+ const s = S.sessions.get(id);
125
+ const t = title.textContent.trim();
126
+ if (commit && t && t !== s.title) api(`/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toastError(e));
127
+ else title.textContent = s.title;
128
+ };
129
+ title.onkeydown = (e) => {
130
+ if (e.key === 'Enter') (e.preventDefault(), title.blur());
131
+ if (e.key === 'Escape') ((title.textContent = S.sessions.get(id).title), title.blur());
132
+ };
133
+ title.onclick = (e) => e.stopPropagation();
134
+ title.onblur = () => done(true);
135
+ }
136
+
137
+ export function moreMenu(id, anchor) {
138
+ const s = S.full.get(id);
139
+ if (!s) return;
140
+ const lastTurn = s.turns?.at(-1);
141
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit);
142
+ const rolledBack = !!lastTurn?.rolledBackAt;
143
+ const r = anchor.getBoundingClientRect();
144
+ const menu = el(`
145
+ <div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
146
+ <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
147
+ <button data-a="copy-id">${icon('copy')}Copy task ID</button>
148
+ ${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
149
+ <button data-a="folder">${icon('folder')}Open project in editor</button>
150
+ ${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
151
+ ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : s.state !== 'running' && s.state !== 'waiting' ? `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>` : ''}
152
+ <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
153
+ </div>`);
154
+ menu.onclick = async (e) => {
155
+ const a = e.target.closest('button')?.dataset.a;
156
+ close();
157
+ if (a === 'rename') rename(id);
158
+ if (a === 'copy-id') copyTaskId(id);
159
+ if (a === 'copy') {
160
+ await tryApi(async () => {
161
+ const { command } = await api(`/sessions/${id}/takeover`);
162
+ copy(command);
163
+ toast('Copied resume command');
164
+ });
165
+ }
166
+ if (a === 'folder') openInEditor(s, '.');
167
+ if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
168
+ if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
169
+ if (a === 'delete') deleteSession(id);
170
+ };
171
+ const close = popover(menu);
172
+ }
173
+
174
+ // Right-click on a feed item.
175
+ export function taskMenu(id, e) {
176
+ const s = S.sessions.get(id);
177
+ if (!s) return;
178
+ $('.menu.task')?.close();
179
+ const busy = s.state === 'running' || s.state === 'waiting';
180
+ const menu = el(`
181
+ <div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
182
+ ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
183
+ <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
184
+ <button data-a="copy-id">${icon('copy')}Copy task ID</button>
185
+ <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
186
+ ${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
187
+ ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
188
+ ${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
189
+ <button data-a="recipe">${icon('spark')}Save as recipe…</button>
190
+ ${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
191
+ ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
192
+ <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
193
+ </div>`);
194
+ menu.onclick = async (ev) => {
195
+ const a = ev.target.closest('button')?.dataset.a;
196
+ close();
197
+ if (a === 'open') expand(id);
198
+ if (a === 'collapse') collapse(id);
199
+ if (a === 'rename') rename(id);
200
+ if (a === 'copy-id') copyTaskId(id);
201
+ if (a === 'rerun') rerun(id);
202
+ if (a === 'ship') shipDialog(id);
203
+ if (a === 'merge') mergePr(id);
204
+ if (a === 'compare') compareView(id);
205
+ if (a === 'recipe') saveAsRecipe(id);
206
+ if (a === 'done') toggleDone(id);
207
+ if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
208
+ if (a === 'delete') deleteSession(id);
209
+ };
210
+ const close = popover(menu);
211
+ const r = menu.getBoundingClientRect();
212
+ if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
213
+ if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
214
+ }
215
+
216
+ export async function deleteSession(id) {
217
+ if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
218
+ const prev = S.sessions.get(id);
219
+ handOff(id)(() => S.sessions.delete(id));
220
+ await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
221
+ if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
222
+ toastError(err);
223
+ });
224
+ }
225
+
226
+ // Launch the task again as a new session in the same folder: same prompt and attachments,
227
+ // optionally edited, on whichever harness, model and thinking level you pick. The original stays.
228
+ export async function rerun(id) {
229
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(toastError));
230
+ const first = s?.turns?.[0];
231
+ if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
232
+ const has = (a) => S.agents.some((x) => x.id === a && x.available);
233
+ let agent = has(s.agent) ? s.agent : S.agents.find((x) => x.available)?.id || s.agent;
234
+ const models = S.agents.find((x) => x.id === agent)?.models || [];
235
+ let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
236
+ let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
237
+ let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || DEFAULT_MODE.id;
238
+ const files = first.attachments || [];
239
+
240
+ const node = el(`
241
+ <div class="modal rerun-modal">
242
+ <header><h2>Rerun task</h2><button class="icon-btn close">${icon('x')}</button></header>
243
+ <div class="rr-box">
244
+ <textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
245
+ ${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
246
+ <div class="l-bar">
247
+ <button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
248
+ <button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
249
+ <span class="grow"></span>
250
+ <button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
251
+ </div>
252
+ </div>
253
+ <p class="hint">Starts a new session in <code>${esc(s.cwd)}</code>. “${esc(s.title)}” is kept as it is.</p>
254
+ </div>`);
255
+ const close = overlay(node);
256
+ $('.close', node).onclick = close;
257
+ const ta = mentionBox($('.rr-prompt', node));
258
+ ta.value = first.prompt;
259
+ autosize(ta);
260
+ const modeBtn = $('[data-a="mode"]', node);
261
+ const paintMode = () => {
262
+ const m = MODES.find((x) => x.id === mode) || DEFAULT_MODE;
263
+ modeBtn.className = `mode-btn m-${m.id}`;
264
+ modeBtn.innerHTML = `${icon(m.icon)}<span>${m.label}</span>`;
265
+ modeBtn.title = m.hint;
266
+ };
267
+ const runnerBtn = $('[data-a="runner"]', node);
268
+ const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
269
+ paintMode();
270
+ paintRunner();
271
+ modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
272
+ // Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
273
+ runnerBtn.onclick = () =>
274
+ runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
275
+ [agent, model, effort] = [a, m, e];
276
+ paintRunner();
277
+ });
278
+
279
+ const go = async () => {
280
+ const prompt = ta.value.trim();
281
+ if (!prompt) return ta.focus();
282
+ close();
283
+ try {
284
+ const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
285
+ const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
286
+ setFocus(next.id);
287
+ } catch (err) {
288
+ toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
289
+ }
290
+ };
291
+ $('[data-a="go"]', node).onclick = go;
292
+ ta.addEventListener('keydown', (e) => {
293
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) (e.preventDefault(), go());
294
+ if (e.key === 'Tab' && e.shiftKey) {
295
+ e.preventDefault();
296
+ mode = MODES[(MODES.findIndex((m) => m.id === mode) + 1) % MODES.length].id;
297
+ paintMode();
298
+ }
299
+ });
300
+ requestAnimationFrame(() => (ta.focus(), ta.setSelectionRange(ta.value.length, ta.value.length)));
301
+ }
302
+
303
+ export async function setArchived(id, archived) {
304
+ const prev = S.sessions.get(id);
305
+ if (!prev) return;
306
+ // Optimistic: the card leaves (or comes back) now; a failed save puts it back.
307
+ move(id, { ...prev, archived }, archived);
308
+ if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
309
+ try {
310
+ await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
311
+ } catch (err) {
312
+ revertSummary(id, prev);
313
+ toastError(err);
314
+ }
315
+ }
316
+
317
+ const EDITORS = {
318
+ vscode: (f, l) => `vscode://file${f}${l ? `:${l}` : ''}`,
319
+ cursor: (f, l) => `cursor://file${f}${l ? `:${l}` : ''}`,
320
+ windsurf: (f, l) => `windsurf://file${f}${l ? `:${l}` : ''}`,
321
+ zed: (f, l) => `zed://file${f}${l ? `:${l}` : ''}`,
322
+ idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
323
+ };
324
+
325
+ // VS Code and its forks open a file on a machine through their Remote - SSH extension.
326
+ const REMOTE_EDITORS = new Set(['vscode', 'cursor', 'windsurf']);
327
+
328
+ export function openInEditor(s, p, line) {
329
+ const editor = S.config.defaults.editor || 'vscode';
330
+ const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
331
+ if (s.machine) {
332
+ if (!REMOTE_EDITORS.has(editor)) {
333
+ copy(abs);
334
+ return toast(`Copied path ${esc(abs)} (on ${esc(s.machine.name)})`);
335
+ }
336
+ location.href = `${editor}://vscode-remote/ssh-remote+${encodeURIComponent(s.machine.target)}${abs}${line ? `:${line}` : ''}`;
337
+ return;
338
+ }
339
+ if (editor === 'none' || !EDITORS[editor]) {
340
+ copy(abs);
341
+ return toast(`Copied path ${esc(abs)}`);
342
+ }
343
+ location.href = EDITORS[editor](abs, line);
344
+ }
@@ -0,0 +1,115 @@
1
+ // Files attached to a prompt: pasted, dropped or picked, uploaded at once, sent as ids.
2
+
3
+ import { esc } from '../md.js';
4
+ import { upload } from '../core/api.js';
5
+ import { el, icon } from '../core/dom.js';
6
+ import { fmtSize } from '../core/format.js';
7
+ import { toast } from '../ui/toast.js';
8
+
9
+ // One file picker for every tray: the tray that opened it gets the files.
10
+ const fileInput = el(`<input type="file" multiple hidden>`);
11
+ document.body.append(fileInput);
12
+
13
+ export function attachTray() {
14
+ const items = [];
15
+ const node = el(`<div class="tray" hidden></div>`);
16
+ const render = () => {
17
+ node.hidden = !items.length;
18
+ node.innerHTML = items
19
+ .map(
20
+ (it, i) => `<div class="att ${it.status}" data-i="${i}" title="${esc(it.name)}">
21
+ ${it.thumb ? `<img src="${it.thumb}" alt="">` : `<span class="att-ic">${icon(it.mime.startsWith('video') ? 'web' : 'file')}</span>`}
22
+ <span class="att-name">${esc(it.name)}</span><span class="att-size">${fmtSize(it.size)}</span>
23
+ <button class="att-x" title="Remove">${icon('x')}</button>
24
+ </div>`,
25
+ )
26
+ .join('');
27
+ };
28
+ node.addEventListener('click', (e) => {
29
+ const x = e.target.closest('.att-x');
30
+ if (x) {
31
+ items.splice(Number(x.closest('.att').dataset.i), 1);
32
+ render();
33
+ }
34
+ });
35
+ const add = (files) => {
36
+ for (const f of files) {
37
+ const it = { name: f.name || 'pasted', size: f.size, mime: f.type || '', status: 'uploading', thumb: f.type.startsWith('image/') ? URL.createObjectURL(f) : null };
38
+ items.push(it);
39
+ it.promise = upload(f, { name: it.name, type: it.mime })
40
+ .then((j) => ((it.id = j.id), (it.status = 'ready'), render()))
41
+ .catch((err) => ((it.status = 'failed'), render(), toast(`Upload failed: ${esc(err.message)}`, { tone: 'bad' })));
42
+ }
43
+ render();
44
+ };
45
+ return {
46
+ node,
47
+ add,
48
+ pick: () => {
49
+ fileInput.onchange = () => {
50
+ add(fileInput.files);
51
+ fileInput.value = '';
52
+ };
53
+ fileInput.click();
54
+ },
55
+ async ids() {
56
+ await Promise.allSettled(items.map((i) => i.promise));
57
+ return items.filter((i) => i.id).map((i) => i.id);
58
+ },
59
+ clear() {
60
+ items.length = 0;
61
+ render();
62
+ },
63
+ // Empty the tray for an optimistic send, and put the files back if the send fails.
64
+ take() {
65
+ const list = items.splice(0);
66
+ render();
67
+ return list;
68
+ },
69
+ restore(list) {
70
+ if (!list?.length) return;
71
+ items.unshift(...list);
72
+ render();
73
+ },
74
+ get busy() {
75
+ return items.some((i) => i.status === 'uploading');
76
+ },
77
+ };
78
+ }
79
+
80
+ // Wire paste + drag/drop of files onto a container.
81
+ export function acceptFiles(container, textarea, tray) {
82
+ textarea.addEventListener('paste', (e) => {
83
+ const files = [...(e.clipboardData?.files || [])];
84
+ if (files.length) {
85
+ e.preventDefault();
86
+ tray.add(files);
87
+ }
88
+ });
89
+ container.addEventListener('dragover', (e) => {
90
+ if ([...e.dataTransfer.types].includes('Files')) {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ container.classList.add('drop');
94
+ }
95
+ });
96
+ container.addEventListener('dragleave', (e) => !container.contains(e.relatedTarget) && container.classList.remove('drop'));
97
+ container.addEventListener('drop', (e) => {
98
+ container.classList.remove('drop');
99
+ if (e.dataTransfer.files.length) {
100
+ e.preventDefault();
101
+ e.stopPropagation();
102
+ tray.add(e.dataTransfer.files);
103
+ }
104
+ });
105
+ }
106
+
107
+ export function autosize(ta, max = 340) {
108
+ const fit = () => {
109
+ ta.style.height = 'auto';
110
+ ta.style.height = `${Math.min(max, ta.scrollHeight)}px`;
111
+ };
112
+ ta.addEventListener('input', fit);
113
+ requestAnimationFrame(fit);
114
+ return fit;
115
+ }