wisp-agents 0.16.0 → 0.18.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 (63) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +6 -5
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +5 -1
  6. package/server/adapters/claude.js +48 -5
  7. package/server/adapters/codex.js +3 -0
  8. package/server/adapters/cursor.js +2 -0
  9. package/server/adapters/grok.js +15 -0
  10. package/server/adapters/index.js +9 -1
  11. package/server/adapters/opencode.js +31 -6
  12. package/server/client.js +1 -1
  13. package/server/git.js +114 -2
  14. package/server/handoff.js +2 -1
  15. package/server/machines.js +2 -2
  16. package/server/mcp-ask.js +113 -8
  17. package/server/mentions.js +39 -0
  18. package/server/openapi.js +21 -3
  19. package/server/protocol.js +8 -1
  20. package/server/render.js +3 -1
  21. package/server/routes/internal.js +121 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/sessions.js +12 -0
  24. package/server/routes/settings.js +51 -4
  25. package/server/routes/ship.js +24 -2
  26. package/server/runner.js +142 -22
  27. package/server/services/auto.js +12 -1
  28. package/server/services/launch.js +12 -8
  29. package/server/services/phone.js +1 -1
  30. package/server/services/ship.js +83 -4
  31. package/server/store.js +23 -2
  32. package/server/triggers.js +1 -1
  33. package/server/usage.js +94 -0
  34. package/server/welcome.js +2 -2
  35. package/web/css/composer.css +22 -0
  36. package/web/css/feed.css +27 -0
  37. package/web/css/launcher.css +17 -0
  38. package/web/css/layout.css +10 -2
  39. package/web/css/modals.css +8 -0
  40. package/web/css/palette.css +22 -0
  41. package/web/css/ship.css +42 -2
  42. package/web/index.html +16 -5
  43. package/web/js/app.js +14 -3
  44. package/web/js/blocks.js +46 -0
  45. package/web/js/core/state.js +2 -1
  46. package/web/js/features/actions.js +9 -4
  47. package/web/js/features/automations.js +1 -1
  48. package/web/js/features/clones.js +79 -0
  49. package/web/js/features/compare.js +2 -1
  50. package/web/js/features/composer.js +11 -7
  51. package/web/js/features/feed.js +3 -3
  52. package/web/js/features/launcher.js +150 -28
  53. package/web/js/features/live.js +3 -3
  54. package/web/js/features/mentions.js +125 -0
  55. package/web/js/features/notify.js +1 -1
  56. package/web/js/features/project-list.js +99 -0
  57. package/web/js/features/projects.js +218 -34
  58. package/web/js/features/runners.js +3 -1
  59. package/web/js/features/settings.js +16 -2
  60. package/web/js/features/setup.js +3 -3
  61. package/web/js/features/ship.js +100 -24
  62. package/web/js/features/turns.js +44 -13
  63. package/web/js/mention-text.js +59 -0
@@ -6,7 +6,7 @@ import { api } from '../core/api.js';
6
6
  import * as bus from '../core/bus.js';
7
7
  import { $, $$, el, icon } from '../core/dom.js';
8
8
  import { money, pct } from '../core/format.js';
9
- import { S } from '../core/state.js';
9
+ import { S, setAgents } from '../core/state.js';
10
10
  import { overlay } from '../ui/overlay.js';
11
11
  import { toast, toastError, tryApi } from '../ui/toast.js';
12
12
  import { openRouter } from './auto.js';
@@ -78,6 +78,7 @@ export function openSettings(opts) {
78
78
  </div>
79
79
  <div class="sx-group">
80
80
  ${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
81
+ ${row('Fix PR conflicts', 'When a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
81
82
  </div>
82
83
  </section>
83
84
  <section class="sx-pane" data-pane="notifications">
@@ -170,7 +171,7 @@ export function openSettings(opts) {
170
171
  btn.classList.add('busy');
171
172
  const before = new Map(S.agents.map((a) => [a.id, a]));
172
173
  try {
173
- S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
174
+ setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
174
175
  if (!node.isConnected) return;
175
176
  paintAgents();
176
177
  $('#launcher')?.placesChanged?.();
@@ -300,6 +301,19 @@ export function openSettings(opts) {
300
301
  });
301
302
  if (!saved) return;
302
303
  paintWt();
304
+ const fc = $('#fixConflicts', node);
305
+ const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts !== 'off');
306
+ fc.onclick = async () => {
307
+ const was = S.config.defaults.fixConflicts;
308
+ S.config.defaults.fixConflicts = was === 'off' ? 'on' : 'off';
309
+ paintFc();
310
+ const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { fixConflicts: S.config.defaults.fixConflicts } } }));
311
+ if (!saved) {
312
+ S.config.defaults.fixConflicts = was;
313
+ paintFc();
314
+ }
315
+ };
316
+ paintFc();
303
317
  renderLauncher();
304
318
  };
305
319
  paintWt();
@@ -6,7 +6,7 @@ import { esc } from '../md.js';
6
6
  import { api } from '../core/api.js';
7
7
  import * as bus from '../core/bus.js';
8
8
  import { $, icon } from '../core/dom.js';
9
- import { launch, S } from '../core/state.js';
9
+ import { launch, S, setAgents } from '../core/state.js';
10
10
  import { toast, toastError } from '../ui/toast.js';
11
11
  import { harnessLogo } from './runners.js';
12
12
 
@@ -25,7 +25,7 @@ export async function installHarness(id) {
25
25
  failed = '';
26
26
  bus.emit('install');
27
27
  try {
28
- S.agents = await api('/agents/install', { method: 'POST', body: { id } });
28
+ setAgents(await api('/agents/install', { method: 'POST', body: { id } }));
29
29
  $('#launcher')?.placesChanged?.();
30
30
  toast(`<b>${esc(S.agents.find((a) => a.id === id)?.name || id)}</b> is ready: free models, no account needed. Type a task and press Enter.`, { timeout: 7000 });
31
31
  $('#prompt')?.focus();
@@ -68,7 +68,7 @@ export function renderSetup() {
68
68
  btn.disabled = true;
69
69
  btn.classList.add('busy');
70
70
  try {
71
- S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
71
+ setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
72
72
  $('#launcher')?.placesChanged?.();
73
73
  bus.emit('agents');
74
74
  if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');
@@ -7,26 +7,42 @@ import { S } from '../core/state.js';
7
7
  import { overlay } from '../ui/overlay.js';
8
8
  import { toast, toastError } from '../ui/toast.js';
9
9
 
10
- const prTone = (pr) =>
11
- pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
10
+ // fix: the session's conflictFix ({ step, why }) while wisp resolves the PR's merge conflicts itself.
11
+ const fixing = (fix) => fix?.step === 'fixing' || fix?.step === 'pushing';
12
+ const prTone = (pr, fix) =>
13
+ pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.conflict ? (fixing(fix) ? 'pending' : 'fail') : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
12
14
  const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
13
- function prLine(pr) {
15
+ function prLine(pr, fix) {
16
+ if (pr.state === 'OPEN' && (pr.conflict || fixing(fix))) {
17
+ const what = fix?.step === 'pushing' ? 'Pushing the conflict fix' : fixing(fix) ? 'Fixing merge conflicts' : fix?.step === 'failed' ? `Conflicts not fixed: ${fix.why}` : 'Merge conflicts';
18
+ return `Open · ${what}`;
19
+ }
14
20
  const c = pr.checks || {};
15
21
  const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
16
22
  const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
17
23
  return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
18
24
  }
19
25
 
26
+ // A ship in flight, per session: { steps, step, update, route, error, values }. The dialog closes
27
+ // on confirm and the card follows along here (the server sends the live stage as `shipping`);
28
+ // a failure stays on the card with Retry, which reopens the dialog with what was typed.
29
+ const ships = new Map();
30
+ const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
31
+ const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
32
+ const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
33
+
20
34
  // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
21
35
  // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
22
36
  export function shippable(s) {
23
- if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
37
+ if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED' || shipping(s)) return false;
24
38
  const t = s.toShip;
25
39
  return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
26
40
  }
27
41
  export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
28
42
 
29
- export const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
43
+ export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}…"><i></i>Shipping</span>` : '');
44
+
45
+ export const prPill = (pr, fix) => `<span class="pill pr pr-${prTone(pr, fix)}" title="PR #${pr.number}: ${esc(prLine(pr, fix))}"><i></i>#${pr.number}</span>`;
30
46
 
31
47
  // The PR row under a card's bar; reads the live summary, so polled check results show up.
32
48
  export function renderPrStrip(id) {
@@ -39,25 +55,65 @@ export function renderPrStrip(id) {
39
55
  b.hidden = !shippable(s);
40
56
  $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
41
57
  });
58
+ // Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
59
+ const run = ships.get(id) || (s.shipping && { steps: s.shipping.update ? ['push', 'pr'] : ['commit', 'push', 'pr'], step: 'push' });
60
+ if (run && STEP_LABEL[s.shipping?.step] && !run.error) run.step = s.shipping.step;
61
+ if (run) return renderShipRun(node, id, run);
62
+ const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
42
63
  node.hidden = !pr;
43
64
  if (!pr) return;
44
- const tone = prTone(pr);
45
- node.className = `pr-strip pr-${tone}`;
65
+ const tone = prTone(pr, s.conflictFix);
66
+ node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
46
67
  node.innerHTML = `
47
68
  <span class="pr-dot"></span>
48
69
  <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
49
- <span class="pr-line">${esc(prLine(pr))}</span>
70
+ <span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
50
71
  <span class="grow"></span>
72
+ ${pr.state === 'OPEN' && pr.conflict && s.conflictFix?.step === 'failed' ? `<button class="ghost xs" data-pr="fix" title="Ask the agent to merge the base in and resolve the conflicts again">${icon('replay')}<span>Fix conflicts</span></button>` : ''}
51
73
  ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
52
74
  <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
53
- ${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
75
+ ${pr.state === 'OPEN' && !pr.conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
54
76
  node.onclick = (e) => {
55
77
  const a = e.target.closest('[data-pr]')?.dataset.pr;
56
78
  if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
57
79
  if (a === 'merge') mergePr(id);
80
+ if (a === 'fix') api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch(toastError);
58
81
  };
59
82
  }
60
83
 
84
+ // The in-flight row: the stages as a track that fills in as the server reports them. Built once
85
+ // and updated in place, so its entry animation and shimmer don't restart with every summary.
86
+ function renderShipRun(node, id, run) {
87
+ const at = Math.max(run.steps.indexOf(run.step), 0);
88
+ if (!node.classList.contains('ship-run') || node.dataset.steps !== run.steps.join()) {
89
+ node.dataset.steps = run.steps.join();
90
+ node.innerHTML = `
91
+ <span class="ship-icon">${icon('send')}</span>
92
+ <span class="ship-what"></span>
93
+ <ol class="ship-track">${run.steps.map((k) => `<li data-step="${k}"><i>${icon('check')}</i><span>${STEP_LABEL[k]}</span></li>`).join('')}</ol>
94
+ <span class="grow"></span>
95
+ <span class="ship-acts"></span>
96
+ <span class="ship-bar"></span>`;
97
+ node.onclick = (e) => {
98
+ const a = e.target.closest('[data-ship]')?.dataset.ship;
99
+ if (a === 'retry') shipDialog(id);
100
+ if (a === 'dismiss') {
101
+ ships.delete(id);
102
+ renderPrStrip(id);
103
+ }
104
+ };
105
+ }
106
+ const failed = !!run.error;
107
+ node.hidden = false;
108
+ node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
109
+ node.title = failed ? run.error : '';
110
+ $$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
111
+ const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}<span class="dots"></span>`;
112
+ if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
113
+ const acts = failed ? `<button class="ghost xs" data-ship="retry">${icon('replay')}<span>Retry</span></button><button class="ghost xs" data-ship="dismiss" title="Dismiss">${icon('x')}</button>` : '';
114
+ if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
115
+ }
116
+
61
117
  export async function shipDialog(id) {
62
118
  const s = S.sessions.get(id);
63
119
  if (!s) return;
@@ -69,6 +125,7 @@ export async function shipDialog(id) {
69
125
  return toastError(err);
70
126
  }
71
127
  const open = info.pr?.state === 'OPEN';
128
+ const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
72
129
  const nothing = !info.dirty && !info.ahead;
73
130
  const node = el(`
74
131
  <div class="modal ship-modal">
@@ -85,23 +142,42 @@ export async function shipDialog(id) {
85
142
  $('.close', node).onclick = close;
86
143
  const title = $('[name="title"]', node);
87
144
  const body = $('[name="body"]', node);
88
- if (title) title.value = info.title;
89
- if (body) body.value = info.body;
90
- const go = async () => {
91
- const btn = $('[data-a="go"]', node);
92
- btn.disabled = true;
93
- btn.lastChild.textContent = 'Shipping…';
94
- try {
95
- const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
96
- close();
97
- toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
98
- } catch (err) {
99
- btn.disabled = false;
100
- btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
101
- toastError(err, { timeout: 10_000 });
145
+ if (title) title.value = typed?.title || info.title;
146
+ if (body) body.value = typed?.body ?? info.body;
147
+ if (typed?.draft && $('[name="draft"]', node)) $('[name="draft"]', node).checked = true;
148
+ // Confirm closes the dialog at once; the card's PR row shows the stages, then the PR itself.
149
+ const go = () => {
150
+ if (title && !title.value.trim()) {
151
+ title.focus();
152
+ return toast('A commit message is required.', { tone: 'bad' });
102
153
  }
154
+ const values = { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked };
155
+ close();
156
+ ships.set(id, { steps: [...(info.dirty ? ['commit'] : []), 'push', 'pr'], step: info.dirty ? 'commit' : 'push', update: open, values });
157
+ renderPrStrip(id);
158
+ const strip = S.nodes.get(id)?.body?.querySelector('.pr-strip');
159
+ if (strip && !strip.hidden) strip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
160
+ api(`/sessions/${id}/ship`, { method: 'POST', body: values }).then(
161
+ (pr) => {
162
+ const s = S.sessions.get(id);
163
+ // Everything just went up: no Push to #n until the server's recheck finds something new.
164
+ if (s) S.sessions.set(id, { ...s, pr: { ...s.pr, ...pr }, shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0 } });
165
+ ships.delete(id);
166
+ renderPrStrip(id);
167
+ toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
168
+ },
169
+ (err) => {
170
+ if (ships.has(id)) ships.get(id).error = err.message;
171
+ const s = S.sessions.get(id);
172
+ if (s) S.sessions.set(id, { ...s, shipping: null });
173
+ renderPrStrip(id);
174
+ toastError(err, { timeout: 10_000 });
175
+ },
176
+ );
177
+ };
178
+ $('[data-a="go"]', node).onclick = () => {
179
+ go();
103
180
  };
104
- $('[data-a="go"]', node).onclick = go;
105
181
  requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
106
182
  }
107
183
 
@@ -2,18 +2,19 @@
2
2
  // and older ones folded.
3
3
 
4
4
  import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
5
- import { esc, md } from '../md.js';
5
+ import { esc, inlineMd, md } from '../md.js';
6
+ import { mentionHtml } from '../mention-text.js';
6
7
  import { api } from '../core/api.js';
7
8
  import { $, $$, el, icon } from '../core/dom.js';
8
9
  import { S } from '../core/state.js';
9
10
  import { overlay } from '../ui/overlay.js';
10
11
  import { toastError } from '../ui/toast.js';
11
- import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver } from './actions.js';
12
+ import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver, toggleDone } from './actions.js';
12
13
  import { draftInto, renderDock, sendReply, startOptimistic, updateDock } from './composer.js';
13
14
  import { scheduleRefetch } from './feed.js';
14
15
  import { feedbackOn, rateTurn, whyForm } from './feedback.js';
15
16
  import { projectById } from './places.js';
16
- import { agentName, effortLabel, modelBadge, MODES } from './runners.js';
17
+ import { agentName, DEFAULT_MODE, effortLabel, modelBadge, MODES } from './runners.js';
17
18
  import { scrollByPx, watching } from './scroll.js';
18
19
  import { renderPrStrip, shipDialog } from './ship.js';
19
20
  import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
@@ -32,9 +33,24 @@ export function ctxFor(s, turn, isLatest) {
32
33
  zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
33
34
  revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
34
35
  exec: (command, key) => execAction(s.id, turn.id, command, key),
36
+ approve: (allow) => approveBlocked(s, turn, allow),
35
37
  };
36
38
  }
37
39
 
40
+ // The "Blocked by permissions" card's buttons. Allow starts the retry, so the card goes to
41
+ // Working at once; a refusal rolls that back and shows why.
42
+ async function approveBlocked(s, turn, allow) {
43
+ const undo = allow ? startOptimistic(s.id, 'Allow & retry what was blocked', null, {}) : null;
44
+ try {
45
+ await api(`/sessions/${s.id}/approve`, { method: 'POST', body: { turnId: turn.id, allow } });
46
+ if (undo) scheduleRefetch(s.id);
47
+ } catch (err) {
48
+ undo?.();
49
+ toastError(err);
50
+ throw err;
51
+ }
52
+ }
53
+
38
54
  // Older turns render as one-line folded rows; past this many they hide behind "Show earlier".
39
55
  const VISIBLE_TURNS = 8;
40
56
 
@@ -54,12 +70,13 @@ export function renderBody(id, { fresh }) {
54
70
  <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
55
71
  <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
56
72
  <button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
73
+ <button class="ghost xs it-done" data-a="done" title="Mark done (E)" hidden>${icon('done')}<span>Mark done</span></button>
57
74
  <button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
58
75
  </div>
59
76
  </div>
60
- <div class="pr-strip" hidden></div>
61
77
  <button class="link xs earlier" hidden></button>
62
78
  <div class="turns"></div>
79
+ <div class="pr-strip" hidden></div>
63
80
  <div class="it-dock"></div>
64
81
  </div>`);
65
82
  n.body ? n.body.replaceWith(body) : n.card.append(body);
@@ -69,6 +86,7 @@ export function renderBody(id, { fresh }) {
69
86
  if (a === 'replay') replay(id);
70
87
  if (a === 'takeover') takeOver(id);
71
88
  if (a === 'ship') shipDialog(id);
89
+ if (a === 'done') toggleDone(id);
72
90
  if (a === 'more') moreMenu(id, e.target.closest('button'));
73
91
  });
74
92
  $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
@@ -77,19 +95,20 @@ export function renderBody(id, { fresh }) {
77
95
 
78
96
  const body = n.body;
79
97
  const proj = projectById(summary.projectId);
80
- // The head already shows the harness, model and project, the task head has Compare, and Done
81
- // sits by the composer. This bar only says how the run was set up: a mode worth noticing
82
- // (Plan or Full auto, as on the launcher), the branch, and what launched it.
98
+ // The head already shows the harness, model and project, and the task head has Compare. This bar
99
+ // says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
100
+ // branch, and what launched it.
83
101
  const mode = MODES.find((m) => m.id === s.mode);
84
102
  const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
85
103
  $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
86
104
  $('.it-meta', body).innerHTML = `
87
- ${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
105
+ ${mode && mode !== DEFAULT_MODE ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
88
106
  ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
89
107
  ${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
90
108
  ${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
91
109
  const live = s.state === 'running' || s.state === 'waiting';
92
- // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
110
+ // Ship and Done sit in the top bar and again next to the composer, so they're there on opening the
111
+ // card and after reading the result. The PR row sits right above the composer for the same reason.
93
112
  renderPrStrip(id);
94
113
  $$('[data-a="done"]', body).forEach((b) => {
95
114
  b.classList.toggle('on', s.state === 'done');
@@ -121,7 +140,7 @@ export function renderBody(id, { fresh }) {
121
140
  const isLatest = t.id === latestId;
122
141
  const folded = !isLatest && !n.unfolded.has(t.id);
123
142
  const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
124
- const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
143
+ const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
125
144
  const prev = n.turnNodes.get(t.id);
126
145
  let node = prev?.node;
127
146
  if (!prev || prev.key !== key) {
@@ -150,7 +169,7 @@ function renderFoldedTurn(t, unfold) {
150
169
  <article class="turn folded" data-turn="${t.id}">
151
170
  <button class="t-fold" title="Show this exchange">
152
171
  ${icon('chevron', 'chev')}
153
- <span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
172
+ <span class="t-prompt">${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
154
173
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
155
174
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
156
175
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
@@ -176,7 +195,7 @@ function renderTurn(s, t, isLatest, fold) {
176
195
  const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
177
196
  const ask = el(`
178
197
  <div class="ask">
179
- <div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
198
+ <div class="ask-text ${long ? 'clamped' : ''}">${mentionHtml(esc(t.prompt))}</div>
180
199
  ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
181
200
  ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
182
201
  <div class="ask-foot">
@@ -264,7 +283,19 @@ function actItem(a) {
264
283
 
265
284
  function renderAskCard(s) {
266
285
  const a = s.ask;
267
- const node = el(`
286
+ const p = a.permission;
287
+ // A live approval: the agent is paused on a tool call its permissions don't allow by themselves.
288
+ const node = p
289
+ ? el(`
290
+ <div class="ask-card perm">
291
+ <div class="q-head">${icon('lock')}<span>Needs your approval</span></div>
292
+ <p class="q-text">${inlineMd(a.question)}</p>
293
+ ${p.detail && !a.question.includes(p.detail) ? `<pre class="perm-detail">${esc(p.detail)}</pre>` : ''}
294
+ ${a.context ? `<p class="q-context">${esc(a.context)}</p>` : ''}
295
+ <div class="q-opts">${a.options.map((o, i) => `<button class="opt ${i === 0 ? 'go' : i === a.options.length - 1 ? 'no' : ''}" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>
296
+ <form class="q-own"><input class="input" placeholder="Or say what to do instead (denies this call)…"><button class="ghost sm">Send</button></form>
297
+ </div>`)
298
+ : el(`
268
299
  <div class="ask-card">
269
300
  <div class="q-head">${icon('ask')}<span>Needs your call</span></div>
270
301
  <p class="q-text">${esc(a.question)}</p>
@@ -0,0 +1,59 @@
1
+ // Task mentions in prompts, without the DOM. A prompt stores one as `@[Title](wisp:<id>)`; the
2
+ // prompt box shows `@Title` and keeps the ids aside (mentions = [{ id, title }]). The server reads
3
+ // the same form (server/mentions.js).
4
+
5
+ export const MENTION = /@\[([^\]\n]{1,200})\]\(wisp:([a-z0-9]{6,40})\)/g;
6
+
7
+ // Titles go inside [...], so no ] or newlines.
8
+ export const cleanTitle = (t) => String(t || 'Untitled task').replace(/[\]\n\r]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'Untitled task';
9
+
10
+ // Stored form → what the box shows, plus the mentions in it.
11
+ export function decode(text = '') {
12
+ const mentions = [];
13
+ const shown = String(text).replace(MENTION, (_, title, id) => {
14
+ if (!mentions.some((m) => m.id === id)) mentions.push({ id, title });
15
+ return `@${title}`;
16
+ });
17
+ return { text: shown, mentions };
18
+ }
19
+
20
+ // Where each mention sits in the shown text: every `@Title` of a known mention, longest titles
21
+ // first so one title that starts another doesn't steal it. Sorted, non-overlapping.
22
+ export function spans(text = '', mentions = []) {
23
+ const out = [];
24
+ for (const m of [...mentions].sort((a, b) => b.title.length - a.title.length)) {
25
+ const needle = `@${m.title}`;
26
+ for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) {
27
+ const end = i + needle.length;
28
+ if (!out.some((s) => i < s.end && end > s.start)) out.push({ start: i, end, id: m.id, title: m.title });
29
+ }
30
+ }
31
+ return out.sort((a, b) => a.start - b.start);
32
+ }
33
+
34
+ // Shown text → stored form. A mention the user edited into something else is just text again.
35
+ export function encode(text = '', mentions = []) {
36
+ let out = '';
37
+ let at = 0;
38
+ for (const s of spans(text, mentions)) {
39
+ out += text.slice(at, s.start) + `@[${s.title}](wisp:${s.id})`;
40
+ at = s.end;
41
+ }
42
+ return out + text.slice(at);
43
+ }
44
+
45
+ // For people: titles, no ids (card titles while a launch is pending).
46
+ export const plain = (text = '') => String(text).replace(MENTION, '@$1');
47
+
48
+ // Already-escaped HTML of a stored prompt → mentions as chips. link: chips open the task.
49
+ export const mentionHtml = (html, { link = true } = {}) =>
50
+ html.replace(MENTION, (_, title, id) => (link ? `<a class="mn" href="#/s/${id}" title="Open this task">@${title}</a>` : `<span class="mn">@${title}</span>`));
51
+
52
+ // Pasted text that is only task references (ids, `wisp:<id>`, `#/s/<id>` links), each a task
53
+ // `known(id)` finds → those ids; anything else → null (pasted as ordinary text).
54
+ export function pastedTasks(text, known) {
55
+ const parts = String(text || '').trim().split(/\s+/).filter(Boolean);
56
+ if (!parts.length || parts.length > 10) return null;
57
+ const ids = parts.map((p) => (p.match(/^(?:wisp:)?([a-f0-9]{12})$/) || p.match(/#\/s\/([a-z0-9]{6,40})\/?$/))?.[1]);
58
+ return ids.every((id) => id && known(id)) ? [...new Set(ids)] : null;
59
+ }