wisp-agents 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +5 -5
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/handoff.js +2 -1
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +3 -2
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/sessions.js +12 -0
- package/server/runner.js +136 -20
- package/server/services/launch.js +1 -1
- package/server/services/phone.js +1 -1
- package/server/store.js +8 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/ship.css +2 -2
- package/web/index.html +1 -0
- package/web/js/blocks.js +46 -0
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/launcher.js +11 -9
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/runners.js +3 -1
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
|
@@ -9,9 +9,11 @@ import { machineById } from './places.js';
|
|
|
9
9
|
|
|
10
10
|
export const MODES = [
|
|
11
11
|
{ id: 'plan', label: 'Plan', icon: 'plan', hint: 'Read-only. The agent investigates and proposes.' },
|
|
12
|
-
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files.
|
|
12
|
+
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Anything else that needs approval comes to you to allow.' },
|
|
13
13
|
{ id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
|
|
14
14
|
];
|
|
15
|
+
// The default: just an icon on mode buttons; the others are spelled out so you can't miss them.
|
|
16
|
+
export const DEFAULT_MODE = MODES[2];
|
|
15
17
|
|
|
16
18
|
export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
|
|
17
19
|
|
package/web/js/features/turns.js
CHANGED
|
@@ -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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|