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.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +5 -5
  3. package/package.json +2 -2
  4. package/server/adapters/antigravity.js +5 -1
  5. package/server/adapters/claude.js +48 -5
  6. package/server/adapters/codex.js +3 -0
  7. package/server/adapters/cursor.js +2 -0
  8. package/server/adapters/grok.js +15 -0
  9. package/server/adapters/index.js +9 -1
  10. package/server/adapters/opencode.js +31 -6
  11. package/server/client.js +1 -1
  12. package/server/handoff.js +2 -1
  13. package/server/mcp-ask.js +113 -8
  14. package/server/mentions.js +39 -0
  15. package/server/openapi.js +3 -2
  16. package/server/protocol.js +8 -1
  17. package/server/render.js +3 -1
  18. package/server/routes/internal.js +121 -1
  19. package/server/routes/sessions.js +12 -0
  20. package/server/runner.js +136 -20
  21. package/server/services/launch.js +1 -1
  22. package/server/services/phone.js +1 -1
  23. package/server/store.js +8 -2
  24. package/server/triggers.js +1 -1
  25. package/server/usage.js +94 -0
  26. package/server/welcome.js +2 -2
  27. package/web/css/composer.css +22 -0
  28. package/web/css/feed.css +27 -0
  29. package/web/css/ship.css +2 -2
  30. package/web/index.html +1 -0
  31. package/web/js/blocks.js +46 -0
  32. package/web/js/features/actions.js +9 -4
  33. package/web/js/features/automations.js +1 -1
  34. package/web/js/features/compare.js +2 -1
  35. package/web/js/features/composer.js +11 -7
  36. package/web/js/features/launcher.js +11 -9
  37. package/web/js/features/mentions.js +125 -0
  38. package/web/js/features/notify.js +1 -1
  39. package/web/js/features/runners.js +3 -1
  40. package/web/js/features/turns.js +44 -13
  41. package/web/js/mention-text.js +59 -0
package/server/usage.js CHANGED
@@ -77,3 +77,97 @@ export function usage(sessions, { days = 30, now = Date.now(), project = () => n
77
77
  runners: sorted(byRunner),
78
78
  };
79
79
  }
80
+
81
+ // Tool calls across sessions, for the agent-facing `wisp_stats` tool: how often each kind of
82
+ // action ran (run, read, edit, search, web, …), per harness, project and day. Kinds are wisp's
83
+ // normalized activity kinds, not the harness's own tool names. Spend and success come from usage().
84
+ const NOT_TOOLS = new Set(['text', 'error', 'answer']);
85
+ const tally = () => ({ calls: 0, kinds: {} });
86
+ const count = (b, kind) => {
87
+ b.calls++;
88
+ b.kinds[kind] = (b.kinds[kind] || 0) + 1;
89
+ };
90
+ const ranked = (m) => [...m.values()].sort((a, b) => b.calls - a.calls);
91
+
92
+ // filter: { agent, project } — agent is a harness id, project a project id or folder name.
93
+ export function toolStats(sessions, { days = 30, now = Date.now(), project = () => null, filter = {} } = {}) {
94
+ const keep = sessions
95
+ .filter((s) => {
96
+ if (!filter.project) return true;
97
+ const p = project(s);
98
+ return p?.id === filter.project || (p?.name || String(s.cwd || '').split(/[\\/]/).pop()) === filter.project;
99
+ })
100
+ .map((s) => (filter.agent ? { ...s, turns: (s.turns || []).filter((t) => (t.agent || s.agent) === filter.agent) } : s));
101
+ const u = usage(keep, { days, now, project });
102
+ const since = u.since;
103
+
104
+ const total = tally();
105
+ const byAgent = new Map();
106
+ const byProject = new Map();
107
+ const byDay = new Map(u.daily.map((d) => [d.date, { date: d.date, ...tally() }]));
108
+ for (const s of keep) {
109
+ if (s.agent === 'wisp') continue;
110
+ const p = project(s);
111
+ for (const t of s.turns || []) {
112
+ if (!t.startedAt || t.startedAt < since || t.status === 'running') continue;
113
+ const agent = t.agent || s.agent;
114
+ const pk = p?.id || s.cwd;
115
+ if (!byAgent.has(agent)) byAgent.set(agent, { agent, ...tally() });
116
+ if (!byProject.has(pk)) byProject.set(pk, { id: p?.id || null, name: p?.name || String(s.cwd || '').split(/[\\/]/).pop(), ...tally() });
117
+ for (const a of t.activity || []) {
118
+ if (!a?.kind || NOT_TOOLS.has(a.kind)) continue;
119
+ count(total, a.kind);
120
+ count(byAgent.get(agent), a.kind);
121
+ count(byProject.get(pk), a.kind);
122
+ const d = byDay.get(dayKey(a.t || t.startedAt));
123
+ if (d) count(d, a.kind);
124
+ }
125
+ }
126
+ }
127
+ const outcome = (b) => ({ turns: b.turns, cost: b.cost, priced: b.priced, successRate: b.successRate });
128
+ const runs = new Map();
129
+ for (const r of u.runners) {
130
+ const b = runs.get(r.agent) || { turns: 0, done: 0, cost: 0, priced: 0 };
131
+ runs.set(r.agent, { turns: b.turns + r.turns, done: b.done + r.done, cost: b.cost + r.cost, priced: b.priced + r.priced });
132
+ }
133
+ const withRuns = (a) => {
134
+ const r = runs.get(a.agent) || { turns: 0, done: 0, cost: 0, priced: 0 };
135
+ return { ...a, turns: r.turns, cost: Math.round(r.cost * 10000) / 10000, priced: r.priced, successRate: r.turns ? r.done / r.turns : null };
136
+ };
137
+ const projectRuns = new Map(u.projects.map((p) => [p.id || p.name, p]));
138
+ return {
139
+ days: u.days,
140
+ since,
141
+ filter: { agent: filter.agent || null, project: filter.project || null },
142
+ total: { ...total, sessions: u.total.sessions, ...outcome(u.total), inputTokens: u.total.inputTokens, outputTokens: u.total.outputTokens, questions: u.total.questions },
143
+ kinds: Object.entries(total.kinds).map(([kind, calls]) => ({ kind, calls })).sort((a, b) => b.calls - a.calls),
144
+ agents: ranked(byAgent).map(withRuns),
145
+ projects: ranked(byProject).map((p) => ({ ...p, ...outcome(projectRuns.get(p.id || p.name) || { turns: 0, cost: 0, priced: 0, successRate: null }) })),
146
+ daily: [...byDay.values()],
147
+ };
148
+ }
149
+
150
+ // toolStats() as markdown for an agent to read.
151
+ export function toolStatsText(st) {
152
+ const pct = (r) => (r === null || r === undefined ? '—' : `${Math.round(r * 100)}%`);
153
+ const usd = (b) => (b.priced ? `$${b.cost.toFixed(2)}${b.priced < b.turns ? ` (${b.priced}/${b.turns} turns priced)` : ''}` : '—');
154
+ const kinds = (k) => Object.entries(k).sort((a, b) => b[1] - a[1]).map(([n, c]) => `${n} ${c}`).join(', ') || '—';
155
+ const scope = [st.filter.agent && `harness ${st.filter.agent}`, st.filter.project && `project ${st.filter.project}`].filter(Boolean).join(', ');
156
+ const t = st.total;
157
+ const out = [
158
+ `## wisp tool stats — last ${st.days} day${st.days === 1 ? '' : 's'}${scope ? ` (${scope})` : ''}`,
159
+ `${t.calls} tool calls in ${t.turns} turns across ${t.sessions} sessions. Success rate ${pct(t.successRate)}, cost ${usd(t)}, ${t.questions} questions to the user, ${t.inputTokens} input / ${t.outputTokens} output tokens.`,
160
+ '',
161
+ 'Kinds are wisp’s categories, not harness tool names: run = shell commands, read / edit / search = files, web = fetch and search, agent = subagents, tool = other (MCP) tools, ask = questions to the user, wait = waiting on the user or a background task.',
162
+ ];
163
+ if (!t.turns) return [...out, '', 'No finished turns in this range.'].join('\n');
164
+ out.push('', '### By kind', '| kind | calls | share |', '|---|---:|---:|');
165
+ for (const k of st.kinds) out.push(`| ${k.kind} | ${k.calls} | ${t.calls ? Math.round((k.calls / t.calls) * 100) : 0}% |`);
166
+ out.push('', '### By harness', '| harness | calls | turns | success | cost | top kinds |', '|---|---:|---:|---:|---:|---|');
167
+ for (const a of st.agents) out.push(`| ${a.agent} | ${a.calls} | ${a.turns} | ${pct(a.successRate)} | ${usd(a)} | ${kinds(a.kinds)} |`);
168
+ out.push('', '### By project', '| project | calls | turns | success | cost | top kinds |', '|---|---:|---:|---:|---:|---|');
169
+ for (const p of st.projects) out.push(`| ${p.name} | ${p.calls} | ${p.turns} | ${pct(p.successRate)} | ${usd(p)} | ${kinds(p.kinds)} |`);
170
+ out.push('', '### By day (days with activity)', '| day | calls | kinds |', '|---|---:|---|');
171
+ for (const d of st.daily.filter((d) => d.calls)) out.push(`| ${d.date} | ${d.calls} | ${kinds(d.kinds)} |`);
172
+ return out.join('\n');
173
+ }
package/server/welcome.js CHANGED
@@ -129,8 +129,8 @@ export function welcomeSession() {
129
129
  title: 'Permission modes (pick per launch)',
130
130
  items: [
131
131
  { text: 'Plan — read-only, the agent proposes', done: true },
132
- { text: 'Edits — the agent may change files, no shell prompts', done: true },
133
- { text: 'Full auto — no permission prompts at all; use on trusted repos', done: false },
132
+ { text: 'Edits — the agent may change files; anything else it needs, you approve', done: true },
133
+ { text: 'Full auto — no permission prompts at all; the default, each task in its own worktree', done: true },
134
134
  ],
135
135
  },
136
136
  {
@@ -60,3 +60,25 @@
60
60
  .c-runner.changed .rb-logo { margin-right: 4px; }
61
61
  .c-runner:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
62
62
  .cr-label { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
63
+
64
+ /* ---------- task mentions (features/mentions.js) ---------- */
65
+ /* In a prompt box: the textarea's own text sits over a mirror that paints a chip under each @Title. */
66
+ .mn-wrap { position: relative; }
67
+ .mn-wrap > textarea { position: relative; z-index: 1; }
68
+ .mn-mirror {
69
+ position: absolute; inset: 0; z-index: 0; margin: 0; overflow: hidden; pointer-events: none;
70
+ border-style: solid; border-color: transparent; color: transparent;
71
+ }
72
+ .mn-wrap:not(.has-mn) .mn-mirror { display: none; }
73
+ .mn-mirror mark.mn {
74
+ color: transparent; border-radius: 4px;
75
+ background: color-mix(in srgb, var(--accent) 20%, transparent);
76
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
77
+ }
78
+ .launcher.docked:not(:focus-within):not(:has(.open)) .mn-wrap { flex: 1; min-width: 0; }
79
+ /* In a sent prompt: the same chip, with the task's title; a link opens that task. */
80
+ :is(a, span).mn {
81
+ padding: 0 5px; border-radius: 5px; font-weight: 500; color: var(--accent-ink); text-decoration: none;
82
+ background: color-mix(in srgb, var(--accent) 12%, transparent); box-decoration-break: clone; -webkit-box-decoration-break: clone;
83
+ }
84
+ a.mn:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
package/web/css/feed.css CHANGED
@@ -96,6 +96,7 @@
96
96
  .it-bar { display: flex; align-items: center; gap: 12px; padding: 8px 10px 8px 14px; }
97
97
  .it-meta { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; }
98
98
  .it-actions { display: flex; gap: 2px; flex: none; }
99
+ .it-done:hover { color: var(--good); }
99
100
  .it-body .earlier { display: block; margin: 4px 0 0 18px; }
100
101
  .it-dock { padding: 4px 14px 14px; }
101
102
 
@@ -206,3 +207,29 @@
206
207
  .q-own { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
207
208
  .ask-card.sent { opacity: 0.5; pointer-events: none; }
208
209
 
210
+ /* approvals: live (paused on a tool call) and after the fact (what the permissions blocked) */
211
+ .ask-card.perm .q-head { color: var(--warn); }
212
+ .ask-card.perm .q-text code { font-size: 0.86em; }
213
+ .perm-detail {
214
+ margin: -4px 0 12px; padding: 9px 12px; max-height: 180px; overflow: auto; border-radius: 8px;
215
+ background: var(--surface-2); border: 1px solid var(--line); font: 12.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word;
216
+ }
217
+ .ask-card.perm .opt.go { border-color: color-mix(in srgb, var(--good) 55%, var(--line-2)); }
218
+ .ask-card.perm .opt.no { color: var(--ink-2); }
219
+ .b-blocked {
220
+ padding: 14px 16px; border-radius: var(--radius); background: var(--surface);
221
+ border: 1px solid color-mix(in srgb, var(--warn) 55%, var(--line));
222
+ }
223
+ .b-blocked.resolved { background: var(--surface-2); border-color: var(--line); }
224
+ .b-blocked .q-head { margin-bottom: 8px; }
225
+ .b-blocked.resolved .q-head { color: var(--muted); }
226
+ .bl-state { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-weight: 500; color: var(--muted); }
227
+ .bl-state svg { width: 13px; height: 13px; }
228
+ .bl-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
229
+ .bl-items li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; }
230
+ .bl-tool { font: 500 11px var(--mono); padding: 1px 6px; border-radius: 5px; background: var(--bg-2); color: var(--ink-2); flex: none; }
231
+ .bl-items code { font: 12.5px/1.45 var(--mono); color: var(--ink); word-break: break-all; min-width: 0; }
232
+ .bl-items small { flex-basis: 100%; color: var(--muted); font-size: 12.5px; }
233
+ .bl-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 12px; }
234
+ .bl-note { font-size: 12px; color: var(--muted); }
235
+
package/web/css/ship.css CHANGED
@@ -53,13 +53,13 @@
53
53
  .pill.pr-merged, .pr-strip.pr-merged { --tone: #8250df; }
54
54
  .pill.pr-closed, .pr-strip.pr-closed { --tone: var(--faint); }
55
55
  .pr-strip {
56
- display: flex; align-items: center; gap: 8px; margin: 0 14px 4px; padding: 5px 6px 5px 10px; border-radius: 9px;
56
+ display: flex; align-items: center; gap: 8px; margin: 8px 14px 4px; padding: 5px 6px 5px 10px; border-radius: 9px;
57
57
  font-size: 12.5px; background: color-mix(in srgb, var(--tone) 7%, transparent); border: 1px solid color-mix(in srgb, var(--tone) 18%, transparent);
58
58
  }
59
59
  .pr-strip[hidden] { display: none; }
60
60
  .pr-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--tone); flex: none; }
61
61
  .pr-strip.pr-pending .pr-dot { animation: pulse 1.4s ease-in-out infinite; }
62
- .pr-num { font-weight: 600; color: var(--ink); }
62
+ .pr-num { font-weight: 600; color: var(--ink); white-space: nowrap; }
63
63
  .pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
64
64
  .pr-strip .ghost .i { width: 13px; height: 13px; }
65
65
  .pr-strip .pr-merge { color: var(--good); font-weight: 500; }
package/web/index.html CHANGED
@@ -42,6 +42,7 @@
42
42
  <symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 11v6M12 7.5v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
43
43
  <symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 3.5l9.5 16.5h-19L12 3.5z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 10v4.5M12 17.2v.3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
44
44
  <symbol id="i-ask" viewBox="0 0 24 24"><path d="M4 5.5A2.5 2.5 0 016.5 3h11A2.5 2.5 0 0120 5.5v8a2.5 2.5 0 01-2.5 2.5H10l-4.5 4v-4h0A1.5 1.5 0 014 14.5v-9z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M10 8.2a2 2 0 113 1.7c-.7.4-1 .8-1 1.6M12 13.2v.3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
45
+ <symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="9.5" rx="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M8.5 10.5V8a3.5 3.5 0 017 0v2.5M12 14.3v2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
45
46
  <symbol id="i-pen" viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19l-4 1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
46
47
  <symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6L12 3zM18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2z" fill="currentColor"/></symbol>
47
48
  <symbol id="i-stop" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"/></symbol>
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(`
@@ -15,8 +15,9 @@ import { saveAsRecipe } from './automations.js';
15
15
  import { compareView } from './compare.js';
16
16
  import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
17
17
  import { launchBehind } from './launcher.js';
18
+ import { copyTaskId, mentionBox } from './mentions.js';
18
19
  import { runnerPicker } from './runner-picker.js';
19
- import { agentName, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
20
+ import { agentName, DEFAULT_MODE, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
20
21
  import { feedAnchor, keepInPlace } from './scroll.js';
21
22
  import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
22
23
 
@@ -143,6 +144,7 @@ export function moreMenu(id, anchor) {
143
144
  const menu = el(`
144
145
  <div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
145
146
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
147
+ <button data-a="copy-id">${icon('copy')}Copy task ID</button>
146
148
  ${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
147
149
  <button data-a="folder">${icon('folder')}Open project in editor</button>
148
150
  ${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
@@ -153,6 +155,7 @@ export function moreMenu(id, anchor) {
153
155
  const a = e.target.closest('button')?.dataset.a;
154
156
  close();
155
157
  if (a === 'rename') rename(id);
158
+ if (a === 'copy-id') copyTaskId(id);
156
159
  if (a === 'copy') {
157
160
  await tryApi(async () => {
158
161
  const { command } = await api(`/sessions/${id}/takeover`);
@@ -178,6 +181,7 @@ export function taskMenu(id, e) {
178
181
  <div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
179
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>`}
180
183
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
184
+ <button data-a="copy-id">${icon('copy')}Copy task ID</button>
181
185
  <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
182
186
  ${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
183
187
  ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
@@ -193,6 +197,7 @@ export function taskMenu(id, e) {
193
197
  if (a === 'open') expand(id);
194
198
  if (a === 'collapse') collapse(id);
195
199
  if (a === 'rename') rename(id);
200
+ if (a === 'copy-id') copyTaskId(id);
196
201
  if (a === 'rerun') rerun(id);
197
202
  if (a === 'ship') shipDialog(id);
198
203
  if (a === 'merge') mergePr(id);
@@ -229,7 +234,7 @@ export async function rerun(id) {
229
234
  const models = S.agents.find((x) => x.id === agent)?.models || [];
230
235
  let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
231
236
  let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
232
- let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || 'edits';
237
+ let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || DEFAULT_MODE.id;
233
238
  const files = first.attachments || [];
234
239
 
235
240
  const node = el(`
@@ -249,12 +254,12 @@ export async function rerun(id) {
249
254
  </div>`);
250
255
  const close = overlay(node);
251
256
  $('.close', node).onclick = close;
252
- const ta = $('.rr-prompt', node);
257
+ const ta = mentionBox($('.rr-prompt', node));
253
258
  ta.value = first.prompt;
254
259
  autosize(ta);
255
260
  const modeBtn = $('[data-a="mode"]', node);
256
261
  const paintMode = () => {
257
- const m = MODES.find((x) => x.id === mode) || MODES[1];
262
+ const m = MODES.find((x) => x.id === mode) || DEFAULT_MODE;
258
263
  modeBtn.className = `mode-btn m-${m.id}`;
259
264
  modeBtn.innerHTML = `${icon(m.icon)}<span>${m.label}</span>`;
260
265
  modeBtn.title = m.hint;
@@ -178,7 +178,7 @@ export function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
178
178
  <div class="k-gh"><label><span>Check every</span><select name="every">${[2, 5, 15, 60].map((m) => `<option value="${m}" ${Number(d.every || 5) === m ? 'selected' : ''}>${m} min</option>`).join('')}</select></label></div>
179
179
  <label><span>Run</span><select name="recipeId"><option value="">This prompt</option>${recipes.map((r) => `<option value="${r.id}" ${r.id === d.recipeId ? 'selected' : ''}>Recipe: ${esc(r.name)}</option>`).join('')}</select></label>
180
180
  <label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea class="textarea mono" name="prompt" rows="6" spellcheck="false"></textarea></label>
181
- <label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
181
+ <label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'auto'))}</select></label>
182
182
  <div class="au-row"><span>Harness</span></div>
183
183
  <p class="au-warn" hidden>${icon('warn')}<span>Issue text comes from GitHub: anyone who can open and label issues can steer this agent. Plan mode only reads; Full auto runs shell commands without asking.</span></p>
184
184
  <div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create trigger'}</button></div>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
4
4
  import { esc } from '../md.js';
5
+ import { mentionHtml } from '../mention-text.js';
5
6
  import { api } from '../core/api.js';
6
7
  import { $, el, icon } from '../core/dom.js';
7
8
  import { STATUS_LABEL } from '../core/format.js';
@@ -37,7 +38,7 @@ export async function compareView(id) {
37
38
  const node = el(`
38
39
  <div class="modal compare-modal">
39
40
  <header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
40
- <p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
41
+ <p class="cmp-prompt">${mentionHtml(esc(fulls[0]?.turns[0]?.prompt || ''))}</p>
41
42
  <div class="cmp-grid" style="--n:${fulls.length}"></div>
42
43
  </div>`);
43
44
  const close = overlay(node);
@@ -1,6 +1,7 @@
1
1
  // The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups.
2
2
 
3
3
  import { esc } from '../md.js';
4
+ import { mentionHtml } from '../mention-text.js';
4
5
  import { api } from '../core/api.js';
5
6
  import { $, el, icon } from '../core/dom.js';
6
7
  import { S } from '../core/state.js';
@@ -10,9 +11,10 @@ import { toggleDone } from './actions.js';
10
11
  import { acceptFiles, attachTray, autosize } from './attachments.js';
11
12
  import { renderFeed, scheduleRefetch, setFocus } from './feed.js';
12
13
  import { feedbackCommand, isFeedback } from './feedback.js';
14
+ import { mentionBox } from './mentions.js';
13
15
  import { placeName } from './places.js';
14
16
  import { runnerPicker } from './runner-picker.js';
15
- import { agentById, agentName, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
17
+ import { agentById, agentName, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
16
18
  import { feedAnchor, keepInPlace, watching } from './scroll.js';
17
19
  import { shipDialog } from './ship.js';
18
20
  import { renderBody } from './turns.js';
@@ -47,7 +49,7 @@ export function renderDock(id) {
47
49
  dock.append(comp);
48
50
  n.tray = attachTray();
49
51
  comp.prepend(n.tray.node);
50
- const ta = $('.reply', comp);
52
+ const ta = mentionBox($('.reply', comp));
51
53
  autosize(ta, 260);
52
54
  acceptFiles(comp, ta, n.tray);
53
55
  $('.c-attach', comp).onclick = n.tray.pick;
@@ -78,7 +80,7 @@ export function renderDock(id) {
78
80
 
79
81
  // The mode the next follow-up runs in: the session's own until the user picks another here.
80
82
  // The pick goes out with that follow-up and stays from then on.
81
- const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || 'edits';
83
+ const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || DEFAULT_MODE.id;
82
84
 
83
85
  function setReplyMode(id, mode) {
84
86
  if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
@@ -88,10 +90,10 @@ function setReplyMode(id, mode) {
88
90
 
89
91
  function paintReplyMode(id, btn) {
90
92
  const s = S.full.get(id);
91
- const m = MODES.find((x) => x.id === replyMode(id)) || MODES[1];
93
+ const m = MODES.find((x) => x.id === replyMode(id)) || DEFAULT_MODE;
92
94
  const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
93
95
  btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
94
- btn.innerHTML = `${icon(m.icon)}${m.id === 'edits' && !from ? '' : `<span>${m.label}</span>`}`;
96
+ btn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE && !from ? '' : `<span>${m.label}</span>`}`;
95
97
  btn.title = from
96
98
  ? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
97
99
  : `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
@@ -157,7 +159,9 @@ export function updateDock(id) {
157
159
  ta.placeholder = tour
158
160
  ? 'This is the tour — launch a real wisp from the box at the top'
159
161
  : s.ask
160
- ? `Answer ${name}…`
162
+ ? s.ask.permission
163
+ ? `Or tell ${name} what to do instead (denies this call)…`
164
+ : `Answer ${name}…`
161
165
  : s.state === 'running'
162
166
  ? queueing()
163
167
  ? `Queue a follow-up — sent when ${name} finishes`
@@ -189,7 +193,7 @@ function renderQueued(s, list, name) {
189
193
  const li = el(`
190
194
  <li class="cq">
191
195
  <span class="cq-tag">${i === 0 ? `Next · after ${esc(name)} finishes` : `Queued #${i + 1}`}${q.run ? ` · on ${esc(runnerTip(q.run.agent, q.run.model, ''))}` : ''}</span>
192
- <span class="cq-text">${esc(q.text)}</span>
196
+ <span class="cq-text">${mentionHtml(esc(q.text), { link: false })}</span>
193
197
  <button class="att-x" title="Cancel this follow-up">${icon('x')}</button>
194
198
  </li>`);
195
199
  const cancel = () => {
@@ -2,6 +2,7 @@
2
2
  // machines, a project in each), on what, and shows a placeholder card until the server answers.
3
3
 
4
4
  import { esc } from '../md.js';
5
+ import { plain } from '../mention-text.js';
5
6
  import { api } from '../core/api.js';
6
7
  import * as bus from '../core/bus.js';
7
8
  import { $, $$, el, icon } from '../core/dom.js';
@@ -14,12 +15,13 @@ import { openAutomations, recipeParams, runRecipe } from './automations.js';
14
15
  import { renderFeed } from './feed.js';
15
16
  import { feedbackCommand, isFeedback } from './feedback.js';
16
17
  import { connectMachine, offlineToast, openMachines } from './machines.js';
18
+ import { mentionBox } from './mentions.js';
17
19
  import { askNotify } from './notify.js';
18
20
  import { MACHINE_STATE, machineById, placeName, placeOf, projectById, projectsAt, projectTitle } from './places.js';
19
21
  import { openProjects } from './projects.js';
20
22
  import { runnerPicker } from './runner-picker.js';
21
23
  import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
22
- import { agentName, agentsAt, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
24
+ import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
23
25
  import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
24
26
  import { canLead, paintTeamBtn, teamEditor } from './team.js';
25
27
 
@@ -38,7 +40,7 @@ function syncWhere() {
38
40
 
39
41
  export function renderLauncher() {
40
42
  const box = $('#launcher');
41
- const mode = S.config.defaults.mode || 'edits';
43
+ const mode = S.config.defaults.mode || DEFAULT_MODE.id;
42
44
  const here = harnessesAt(launch.where, { auto: true });
43
45
  const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
44
46
  let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
@@ -63,9 +65,9 @@ export function renderLauncher() {
63
65
  renderLaunchChips();
64
66
  renderSetup();
65
67
 
66
- const ta = $('#prompt', box);
68
+ const ta = mentionBox($('#prompt', box));
67
69
  const tray = attachTray();
68
- ta.before(tray.node);
70
+ ta.parentNode.before(tray.node); // before the mention box's wrapper
69
71
  acceptFiles(box, ta, tray);
70
72
  autosize(ta);
71
73
  ta.value = S.drafts.get('home') || '';
@@ -88,14 +90,14 @@ export function renderLauncher() {
88
90
  $('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
89
91
  paintWhereBtn();
90
92
 
91
- // Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
92
- // spelled out when it's Plan or Full auto so you can't miss it.
93
+ // Mode is set-and-forget, so it's one small button: just an icon on the default (Full auto),
94
+ // spelled out when it's Plan or Edits so you can't miss it.
93
95
  let curMode = mode;
94
96
  const modeBtn = $('#mode', box);
95
97
  const paintMode = () => {
96
- const m = MODES.find((x) => x.id === curMode) || MODES[1];
98
+ const m = MODES.find((x) => x.id === curMode) || DEFAULT_MODE;
97
99
  modeBtn.className = `mode-btn m-${m.id}`;
98
- modeBtn.innerHTML = `${icon(m.icon)}${m.id === 'edits' ? '' : `<span>${m.label}</span>`}`;
100
+ modeBtn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE ? '' : `<span>${m.label}</span>`}`;
99
101
  modeBtn.title = `${m.label}: ${m.hint}\nShift+Tab to switch`;
100
102
  };
101
103
  const setMode = (id) => {
@@ -486,7 +488,7 @@ function launchedFx(from, to) {
486
488
  let pendingSeq = 0;
487
489
  function addPending(prompt, { project: p, m }, { agent, model, effort }) {
488
490
  const id = `pending-${++pendingSeq}`;
489
- const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
491
+ const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
490
492
  const now = Date.now();
491
493
  S.sessions.set(id, {
492
494
  id,
@@ -0,0 +1,125 @@
1
+ // Task mentions in a prompt box, the way Linear does issue links: copy a task's id (right-click a
2
+ // card → Copy task ID), paste it into a prompt, and it shows as the task's title. The agent gets
3
+ // the id and reads that task with wisp_session.
4
+ //
5
+ // A textarea can't hold chips, so the box shows `@Title` as plain text and a mirror behind it,
6
+ // laid out exactly like the textarea, paints a chip under each mention. The ids stay on the box
7
+ // (ta.mentions). Its `value` reads and writes the stored form, `@[Title](wisp:<id>)`, so drafts,
8
+ // sends and restores elsewhere keep the mentions without knowing about them.
9
+
10
+ import { esc } from '../md.js';
11
+ import { el } from '../core/dom.js';
12
+ import { S } from '../core/state.js';
13
+ import { toast } from '../ui/toast.js';
14
+ import { cleanTitle, decode, encode, pastedTasks, spans } from '../mention-text.js';
15
+
16
+ const NATIVE = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
17
+ // What the mirror copies from the textarea, so its text wraps on the same lines.
18
+ const METRICS = ['font', 'letterSpacing', 'wordSpacing', 'lineHeight', 'textTransform', 'textIndent', 'tabSize', 'whiteSpace', 'overflowWrap', 'wordBreak', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'boxSizing'];
19
+
20
+ export function mentionBox(ta) {
21
+ if (ta.mentions) return ta;
22
+ ta.mentions = [];
23
+ const wrap = el('<div class="mn-wrap"></div>');
24
+ const mirror = el('<div class="mn-mirror" aria-hidden="true"></div>');
25
+ ta.replaceWith(wrap);
26
+ wrap.append(mirror, ta);
27
+ const shown = () => NATIVE.get.call(ta);
28
+
29
+ Object.defineProperty(ta, 'value', {
30
+ configurable: true,
31
+ get: () => encode(shown(), ta.mentions),
32
+ set: (v) => {
33
+ const d = decode(v);
34
+ ta.mentions = d.mentions;
35
+ NATIVE.set.call(ta, d.text);
36
+ paint();
37
+ },
38
+ });
39
+
40
+ function paint() {
41
+ const text = shown();
42
+ const found = ta.mentions.length ? spans(text, ta.mentions) : [];
43
+ wrap.classList.toggle('has-mn', found.length > 0);
44
+ if (!found.length) return (mirror.textContent = '');
45
+ const cs = getComputedStyle(ta);
46
+ for (const k of METRICS) mirror.style[k] = cs[k];
47
+ // A scrollbar narrows the textarea's text; narrow the mirror's the same.
48
+ const bar = ta.offsetWidth - ta.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth);
49
+ mirror.style.paddingRight = `${parseFloat(cs.paddingRight) + Math.max(0, bar)}px`;
50
+ let html = '';
51
+ let at = 0;
52
+ for (const s of found) {
53
+ html += `${esc(text.slice(at, s.start))}<mark class="mn">${esc(text.slice(s.start, s.end))}</mark>`;
54
+ at = s.end;
55
+ }
56
+ mirror.innerHTML = `${html}${esc(text.slice(at))}​`;
57
+ sync();
58
+ }
59
+ const sync = () => {
60
+ mirror.scrollTop = ta.scrollTop;
61
+ mirror.scrollLeft = ta.scrollLeft;
62
+ };
63
+
64
+ ta.addEventListener('input', paint);
65
+ ta.addEventListener('scroll', sync);
66
+ new ResizeObserver(paint).observe(ta);
67
+
68
+ ta.addEventListener('paste', (e) => {
69
+ if (e.clipboardData?.files?.length) return; // attachments take those
70
+ const ids = pastedTasks(e.clipboardData?.getData('text/plain'), (id) => S.sessions.has(id) && !S.sessions.get(id).pending);
71
+ if (!ids) return;
72
+ e.preventDefault();
73
+ for (const id of ids) {
74
+ const title = cleanTitle(S.sessions.get(id).title);
75
+ if (!ta.mentions.some((m) => m.id === id)) ta.mentions.push({ id, title });
76
+ }
77
+ const before = shown().slice(0, ta.selectionStart);
78
+ const lead = before && !/\s$/.test(before) ? ' ' : '';
79
+ insert(lead + ids.map((id) => `@${ta.mentions.find((m) => m.id === id).title}`).join(' ') + ' ');
80
+ });
81
+
82
+ // Backspace right after a mention (or Delete right before one) takes the whole mention.
83
+ ta.addEventListener('keydown', (e) => {
84
+ if ((e.key !== 'Backspace' && e.key !== 'Delete') || e.isComposing || e.altKey || e.metaKey || e.ctrlKey) return;
85
+ const { selectionStart: a, selectionEnd: b } = ta;
86
+ if (a !== b || !ta.mentions.length) return;
87
+ const hit = spans(shown(), ta.mentions).find((s) => (e.key === 'Backspace' ? s.end === a : s.start === a));
88
+ if (!hit) return;
89
+ e.preventDefault();
90
+ ta.setSelectionRange(hit.start, hit.end);
91
+ insert('');
92
+ });
93
+
94
+ // Through the editing commands, so Cmd+Z undoes it like typing.
95
+ function insert(text) {
96
+ if (!document.execCommand('insertText', false, text)) {
97
+ ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
98
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
99
+ }
100
+ }
101
+
102
+ paint();
103
+ return ta;
104
+ }
105
+
106
+ export async function copyTaskId(id) {
107
+ const s = S.sessions.get(id);
108
+ if (!s || s.pending) return;
109
+ try {
110
+ await navigator.clipboard.writeText(id);
111
+ toast(`Copied the ID of <b>${esc(cleanTitle(s.title))}</b>. Paste it into a prompt to point an agent at this task.`);
112
+ } catch {
113
+ toast(`Couldn’t copy. The task ID is <code>${id}</code>`, { tone: 'bad', timeout: 8000 });
114
+ }
115
+ }
116
+
117
+ // Clicking a mention chip in a turn's prompt opens that task, or says it's gone.
118
+ document.addEventListener('click', (e) => {
119
+ const a = e.target.closest?.('a.mn[href^="#/s/"]');
120
+ if (!a) return;
121
+ const id = a.getAttribute('href').slice(4);
122
+ if (S.sessions.has(id)) return; // the #/s/ route reveals it
123
+ e.preventDefault();
124
+ toast('That task is no longer in wisp.', { tone: 'bad' });
125
+ });
@@ -20,7 +20,7 @@ export function askNotify() {
20
20
  export function notifyFinished(s) {
21
21
  const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
22
22
  if (viewing) return;
23
- let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
23
+ let verb = s.state === 'waiting' ? (s.approval ? 'needs your approval' : 'has a question') : 'is ready';
24
24
  // A task run in several places (or compared) says so once, when its last run finishes.
25
25
  const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
26
26
  if (runs.length > 1 && s.state !== 'waiting') {