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/openapi.js CHANGED
@@ -226,7 +226,8 @@ export function openapi({ version, port }) {
226
226
  responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
227
227
  },
228
228
  },
229
- '/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
229
+ '/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question (or approval: Allow, Always allow in this task, Deny, or what to do instead)', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
230
+ '/sessions/{id}/approve': { parameters: [id], post: { summary: 'Allow and retry (or deny) what a finished turn’s permissions blocked', requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, allow: { type: 'boolean', description: 'Default true' } } }), responses: { 200: ok } } },
230
231
  '/sessions/{id}/revert': {
231
232
  parameters: [id],
232
233
  post: {
@@ -292,7 +293,7 @@ export function openapi({ version, port }) {
292
293
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
293
294
  agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
294
295
  important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
295
- mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
296
+ mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
296
297
  model: { type: 'string' },
297
298
  effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
298
299
  attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
@@ -69,6 +69,9 @@ diff, the test run, the command output — over prose. Use file paths relative t
69
69
  A screenshot or generated image on disk.
70
70
  - {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
71
71
  When you need a decision. The user's click resumes you with the reply.
72
+ - {"type":"blocked","items":[{"tool":"shell","detail":"npm publish","why":"needs network access"}]}
73
+ Actions your permissions or sandbox refused (a denied tool call, a command that failed for lack of
74
+ network or write access). wisp shows them with an Allow & retry button. Not for ordinary failures.
72
75
  - {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
73
76
  One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
74
77
  - {"type":"next","items":["Suggested follow-up the user might send", "…"]}
@@ -89,7 +92,11 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
89
92
 
90
93
  const ASK_TOOL = (tool) => `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
91
94
  two very different approaches), call the \`ask_user\` tool (${tool}). It pauses you until
92
- the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.`;
95
+ the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.
96
+ - If the user asks about their past wisp tasks (which tools or harnesses they use, cost, success rate),
97
+ call \`wisp_stats\` (${tool.replace(/ask_user$/, 'wisp_stats')}) instead of digging through files.
98
+ - If the prompt mentions another wisp task ("@Title (wisp task <id>)") or the user asks what another task
99
+ did, read it with \`wisp_session\` (${tool.replace(/ask_user$/, 'wisp_session')}); \`wisp_sessions\` lists them.`;
93
100
  const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
94
101
  two very different approaches), stop before acting on it and end with a "question" block (2–4 options).
95
102
  The user's answer comes back as a follow-up in this same session.`;
package/server/render.js CHANGED
@@ -80,6 +80,8 @@ const R = {
80
80
  callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
81
81
  image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
82
82
  question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
83
+ blocked: (b, turn) =>
84
+ `**🔒 Blocked by permissions**${turn?.approval ? ` _(${turn.approval})_` : ''}\n${list(b.items).map((i) => `- ${str(i.tool)}${i.detail ? `: \`${str(i.detail)}\`` : ''}${i.why ? ` — ${i.why}` : ''}`).join('\n')}`,
83
85
  actions: (b) =>
84
86
  `${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
85
87
  .map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
@@ -109,7 +111,7 @@ export function sessionToMarkdown(session) {
109
111
  if (turn?.status === 'running') out.push(`_Working… (${turn.activity?.findLast?.((a) => a.kind !== 'text')?.label || 'starting up'})_`);
110
112
  else if (turn?.presentation) out.push(blocksToMarkdown(turn.presentation.blocks, turn));
111
113
  if (session.ask) {
112
- out.push(`**❓ The agent is waiting for your answer:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
114
+ out.push(`**${session.ask.permission ? '🔒 The agent needs your approval' : '❓ The agent is waiting for your answer'}:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
113
115
  if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
114
116
  }
115
117
  // Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
@@ -1,8 +1,14 @@
1
- // Bridge for the MCP servers running inside an agent process (ask_user, delegate). Every call
1
+ // Bridge for the MCP servers running inside an agent process (ask_user, approve, delegate, wisp_stats,
2
+ // wisp_sessions, wisp_session). Every call
2
3
  // carries the per-run token wisp handed the agent; nothing else may use these.
3
4
 
4
5
  import { adapters } from '../adapters/index.js';
6
+ import { plainMentions } from '../mentions.js';
7
+ import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
8
+ import { summarize } from '../runner.js';
5
9
  import { delegationReport } from '../subagents.js';
10
+ import { toolStats, toolStatsText } from '../usage.js';
11
+ import { clip } from '../util.js';
6
12
 
7
13
  export function register(r, app) {
8
14
  const { store, runner, telemetry } = app;
@@ -25,6 +31,22 @@ export function register(r, app) {
25
31
  );
26
32
  r.route('GET', '/internal/ask/:id', guarded((c) => c.send(200, runner.pollAnswer(c.params.id))), opts);
27
33
 
34
+ // A live approval (the harness's permission prompt): { decision } at once, or an { id } to poll
35
+ // like ask_user, whose answer carries the decision.
36
+ r.route(
37
+ 'POST',
38
+ '/internal/approve',
39
+ guarded(async (c) => {
40
+ const body = await c.body();
41
+ const s = store.get(body.session);
42
+ if (!s) return c.send(404, { error: 'no such session' });
43
+ const out = runner.requestApproval(s, body);
44
+ if (out.id) telemetry.track('approval', { agent: s.agent });
45
+ c.send(200, out);
46
+ }),
47
+ opts,
48
+ );
49
+
28
50
  // delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
29
51
  r.route(
30
52
  'POST',
@@ -53,4 +75,102 @@ export function register(r, app) {
53
75
  }),
54
76
  opts,
55
77
  );
78
+
79
+ // wisp_stats: read-only tool counts, spend and success across sessions. project=current is the
80
+ // asking session's project.
81
+ r.route(
82
+ 'GET',
83
+ '/internal/stats/:id',
84
+ guarded((c) => {
85
+ const s = lead(c);
86
+ if (!s) return c.send(404, { error: 'no such session' });
87
+ const projectFor = (x) => store.projectFor(x.cwd);
88
+ let project = c.query.get('project') || '';
89
+ if (project === 'current') project = projectFor(s)?.id || String(s.cwd || '').split(/[\\/]/).pop();
90
+ const stats = toolStats(store.all(), { days: Number(c.query.get('days')) || 30, project: projectFor, filter: { agent: c.query.get('agent') || '', project } });
91
+ telemetry.track('wisp_stats', { agent: s.agent });
92
+ c.send(200, { stats, text: toolStatsText(stats) });
93
+ }),
94
+ opts,
95
+ );
96
+ // wisp_sessions: the user's other tasks (not the asker, its subagents or the tour), newest first.
97
+ r.route(
98
+ 'GET',
99
+ '/internal/sessions/:id',
100
+ guarded((c) => {
101
+ const s = lead(c);
102
+ if (!s) return c.send(404, { error: 'no such session' });
103
+ const q = Object.fromEntries(c.query);
104
+ let project = q.project || '';
105
+ if (project === 'current') project = store.projectFor(s.cwd)?.id || String(s.cwd || '').split(/[\\/]/).pop();
106
+ const states = q.state ? q.state.split(',').map((x) => x.trim()) : null;
107
+ const words = String(q.query || '').toLowerCase().split(/\s+/).filter(Boolean);
108
+ const all = store
109
+ .all()
110
+ .filter((x) => x.id !== s.id && !x.parentId && x.agent !== 'wisp' && x.id !== s.parentId)
111
+ .map((x) => summarize(x, store))
112
+ .filter((x) => (q.archived === 'true' || !x.archived) && (!states || states.includes(x.state)))
113
+ .filter((x) => !project || x.projectId === project || x.project === project)
114
+ .filter((x) => words.every((w) => `${x.title} ${x.preview}`.toLowerCase().includes(w)))
115
+ .sort((a, b) => b.updatedAt - a.updatedAt);
116
+ const list = all.slice(0, Math.min(Math.max(Number(q.limit) || 20, 1), 100));
117
+ telemetry.track('wisp_sessions', { agent: s.agent });
118
+ const text = list.length
119
+ ? `${all.length} task${all.length === 1 ? '' : 's'}${all.length > list.length ? `, the newest ${list.length}` : ''}. Read one with wisp_session.\n\n${list.map(sessionLine).join('\n')}`
120
+ : 'No tasks match.';
121
+ c.send(200, { text });
122
+ }),
123
+ opts,
124
+ );
125
+
126
+ // wisp_session: one task's requests and a turn's report (?of=<id>&turn=<1-based>).
127
+ r.route(
128
+ 'GET',
129
+ '/internal/session/:id',
130
+ guarded((c) => {
131
+ const s = lead(c);
132
+ if (!s) return c.send(404, { error: 'no such session' });
133
+ const id = String(c.query.get('of') || '').trim().replace(/^wisp:/, '');
134
+ const x = store.get(id);
135
+ telemetry.track('wisp_session', { agent: s.agent });
136
+ if (!x) return c.send(200, { text: `No wisp task \`${id}\` on this computer. It may have been deleted, or it ran on another machine. List tasks with wisp_sessions.` });
137
+ c.send(200, { text: sessionText(x, store, c.query.get('turn')) });
138
+ }),
139
+ opts,
140
+ );
141
+ }
142
+
143
+ const age = (t) => {
144
+ const m = Math.round((Date.now() - t) / 60_000);
145
+ return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
146
+ };
147
+
148
+ const sessionLine = (x) =>
149
+ `- \`${x.id}\` **${x.title}** — ${x.archived ? 'archived, ' : ''}${x.state}, ${x.agent}${x.model ? ` (${x.model})` : ''}, ${x.project}, ${age(x.updatedAt)}${x.state === 'running' && x.live ? `: ${x.live}` : x.preview ? `: ${clip(x.preview, 160)}` : ''}`;
150
+
151
+ export function sessionText(x, store, turnArg) {
152
+ const sum = summarize(x, store);
153
+ const n = Number(turnArg);
154
+ const turn = turnArg === null || turnArg === undefined || turnArg === '' || !n ? x.turns.at(-1) : x.turns[n - 1];
155
+ const head = [
156
+ `# ${x.title}`,
157
+ `Task \`${x.id}\` · ${sum.archived ? 'archived · ' : ''}${x.state} · ${x.agent}${x.model ? ` (${x.model})` : ''} · project ${sum.project} · updated ${age(x.updatedAt)}`,
158
+ `Folder: \`${x.worktree?.path || x.cwd}\`${x.worktree ? ` (worktree, branch \`${x.worktree.branch}\`)` : ''}${x.pr ? ` · PR #${x.pr.number} (${String(x.pr.state).toLowerCase()}): ${x.pr.url}` : ''}`,
159
+ ];
160
+ const asks = x.turns.map((t, i) => `${i + 1}. ${clip(plainMentions(t.prompt).replace(/\s+/g, ' '), 300)}${t.status === 'running' ? ' _(running)_' : t.status === 'error' ? ' _(failed)_' : t.status === 'stopped' ? ' _(stopped)_' : ''}`);
161
+ if (!turn) return [...head, '', `Turn ${turnArg} doesn't exist; this task has ${x.turns.length}.`, '', '## Requests', ...asks].join('\n');
162
+ const i = x.turns.indexOf(turn);
163
+ const report = turn === x.turns.at(-1) ? sessionToMarkdown(x) : blocksToMarkdown(turn.presentation?.blocks, turn);
164
+ const files = (turn.changes || []).filter((ch) => !ch.reverted).map((ch) => `\`${ch.file}\``);
165
+ return [
166
+ ...head,
167
+ '',
168
+ `## Requests (${x.turns.length})`,
169
+ ...asks,
170
+ '',
171
+ `## Report for turn ${i + 1}${x.turns.length > 1 ? ` of ${x.turns.length} (pass turn to read another)` : ''}`,
172
+ '',
173
+ report || '_No report yet._',
174
+ ...(files.length ? ['', `Files edited in this turn: ${files.join(', ')}`] : []),
175
+ ].join('\n');
56
176
  }
@@ -206,6 +206,18 @@ export function register(r, app) {
206
206
  }),
207
207
  );
208
208
 
209
+ // The card under a turn whose permissions blocked something: allow retries it, deny closes it.
210
+ r.route(
211
+ 'POST',
212
+ '/sessions/:id/approve',
213
+ one(async (c, s) => {
214
+ const { turnId, allow } = await c.body();
215
+ notTour(s, 'approve');
216
+ await ensureWorktree(s.worktree);
217
+ c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
218
+ }),
219
+ );
220
+
209
221
  // Revert (or re-apply) a single file's edits from a turn, or roll back (or re-apply) the
210
222
  // project's working tree to the checkpoint before a turn. Both note it on the turn and tell
211
223
  // the cards; Ship it may have something new (or nothing left) to ship.
package/server/runner.js CHANGED
@@ -11,6 +11,7 @@ import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
11
11
  import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
12
12
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
13
13
  import { handoffNote } from './handoff.js';
14
+ import { agentPrompt, plainMentions } from './mentions.js';
14
15
  import { bad, MODES, newId } from './util.js';
15
16
 
16
17
  const MAX_ACTIVITY = 400;
@@ -19,6 +20,26 @@ const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its backgr
19
20
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
20
21
  // The delegate tool lives on the same injected server, so harnesses name it like ask_user.
21
22
  const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
23
+ // The answers a live approval offers. Typed text instead is a "no, do this instead".
24
+ export const APPROVAL = { allow: 'Allow', always: 'Always allow in this task', deny: 'Deny' };
25
+ const sameAction = (a, b) => a.tool === b.tool && a.detail === b.detail;
26
+
27
+ // A live approval's answer: the button the user clicked, a yes/no typed out, or anything else,
28
+ // which denies the call and hands the agent the user's words.
29
+ export function approvalDecision(text) {
30
+ const t = String(text || '').trim();
31
+ if (t === APPROVAL.always || /^(always\b|2$)/i.test(t)) return { decision: 'allow', always: true };
32
+ if (t === APPROVAL.allow || /^(y|yes|ok|allow|approve|go ahead|1)$/i.test(t)) return { decision: 'allow' };
33
+ if (t === APPROVAL.deny || /^(n|no|deny|reject|3)$/i.test(t)) return { decision: 'deny' };
34
+ return { decision: 'deny', message: t };
35
+ }
36
+
37
+ // The follow-up that retries what a turn's permissions refused, once the user allowed it.
38
+ function retryPrompt(items, scoped) {
39
+ const list = items.map((b) => `- ${b.tool}${b.detail ? `: \`${b.detail}\`` : ''}`).join('\n');
40
+ const how = scoped ? 'I allowed exactly these for this follow-up' : 'This follow-up runs in Full auto, so nothing is blocked';
41
+ return `These actions were blocked by your permissions last turn:\n${list}\n\n${how}. Run them now and finish the task.`;
42
+ }
22
43
 
23
44
  export { newId };
24
45
 
@@ -43,7 +64,7 @@ function changedRun(session, run) {
43
64
  }
44
65
 
45
66
  function autoTitle(prompt) {
46
- const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
67
+ const line = plainMentions(prompt).trim().split('\n')[0].replace(/\s+/g, ' ');
47
68
  return line.length > 64 ? `${line.slice(0, 63)}…` : line;
48
69
  }
49
70
 
@@ -78,6 +99,7 @@ export function summarize(s, store) {
78
99
  live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
79
100
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
80
101
  question: s.ask?.question || '',
102
+ approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
81
103
  branch: s.worktree?.branch || '',
82
104
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
83
105
  pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
@@ -122,7 +144,7 @@ export class Runner extends EventEmitter {
122
144
  }
123
145
 
124
146
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
125
- create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
147
+ create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
126
148
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
127
149
  const now = Date.now();
128
150
  const session = {
@@ -158,11 +180,12 @@ export class Runner extends EventEmitter {
158
180
  // mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
159
181
  // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
160
182
  // switches the harness, model and thinking level the same way (see #switchRun).
161
- reply(session, text, attachments = [], { queue = false, mode, run } = {}) {
183
+ // grants / once: a retry of what the last turn's permissions blocked (see approveBlocked).
184
+ reply(session, text, attachments = [], { queue = false, mode, run, grants, once } = {}) {
162
185
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
163
186
  run = changedRun(session, run);
164
- const extra = { ...(mode && { mode }), ...(run && { run }) };
165
- if (session.ask && !mode && !run) {
187
+ const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
188
+ if (session.ask && !Object.keys(extra).length) {
166
189
  this.answer(session, text);
167
190
  return { answered: true };
168
191
  }
@@ -190,10 +213,29 @@ export class Runner extends EventEmitter {
190
213
  }
191
214
  if (mode) session.mode = mode;
192
215
  if (run) this.#switchRun(session, run);
193
- this.#startTurn(session, text, attachments);
216
+ this.#startTurn(session, text, attachments, extra);
194
217
  return { queued: false };
195
218
  }
196
219
 
220
+ // The card under a turn whose permissions refused something: allow retries it as a follow-up,
221
+ // with exactly those actions allowed where the harness can scope a grant, else in Full auto for
222
+ // that one follow-up. Deny just closes the card.
223
+ approveBlocked(session, { turnId, allow }) {
224
+ const turn = turnId ? session.turns.find((t) => t.id === turnId) : session.turns.at(-1);
225
+ if (!turn?.blocked?.length) throw bad('Nothing on that turn is waiting for approval.', 404);
226
+ if (turn.approval) throw bad(`Already ${turn.approval}.`, 409);
227
+ if (turn !== session.turns.at(-1) || this.live.has(session.id)) throw bad('The task has moved on since; send a follow-up instead.', 409);
228
+ turn.approval = allow ? 'allowed' : 'denied';
229
+ if (!allow) {
230
+ this.#touch(session, { turn: turn.id });
231
+ return { denied: true };
232
+ }
233
+ const scoped = turn.blocked.every((b) => b.rule !== undefined);
234
+ const out = this.reply(session, retryPrompt(turn.blocked, scoped), [], scoped ? { grants: turn.blocked.map((b) => b.rule) } : { once: 'auto' });
235
+ this.#touch(session, { turn: turn.id });
236
+ return { ...out, scoped };
237
+ }
238
+
197
239
  // A follow-up on another harness, model or thinking level. A model or level change resumes the
198
240
  // same conversation; a harness change resumes that harness's own one (if it ran earlier in this
199
241
  // task) or starts it fresh, and #startTurn hands it the turns it missed.
@@ -222,7 +264,8 @@ export class Runner extends EventEmitter {
222
264
  }
223
265
 
224
266
  // ask_user: the agent is blocked inside a tool call until answer() is called.
225
- ask(session, { question, context, options }) {
267
+ // permission: set for a live approval ({ tool, action, detail }), see requestApproval().
268
+ ask(session, { question, context, options, permission }) {
226
269
  const turn = session.turns.at(-1);
227
270
  session.ask = {
228
271
  id: newId(),
@@ -230,34 +273,66 @@ export class Runner extends EventEmitter {
230
273
  context: context ? String(context) : '',
231
274
  options: Array.isArray(options) ? options.map(String).slice(0, 6) : [],
232
275
  askedAt: Date.now(),
276
+ ...(permission && { permission }),
233
277
  };
234
278
  session.state = 'waiting';
235
279
  session.unread = true;
236
280
  delete session.archived; // a question needs the user, so it comes back to the feed
237
- if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label: `Asked you: ${session.ask.question}` });
281
+ const label = permission ? `Needs your OK to ${permission.action}` : `Asked you: ${session.ask.question}`;
282
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label });
238
283
  this.#touch(session, { turn: turn?.id });
239
284
  this.emit('finished', { session: summarize(session, this.store) });
240
285
  return session.ask.id;
241
286
  }
242
287
 
288
+ // Live approval: the harness asks before a tool call its permission mode doesn't allow on its
289
+ // own (Claude's --permission-prompt-tool). It waits on the answer like ask_user; something the
290
+ // user said "always" to earlier in this task goes through at once.
291
+ requestApproval(session, { tool, input }) {
292
+ const adapter = adapters[session.agent];
293
+ const p = adapter.permission?.(String(tool || ''), input && typeof input === 'object' ? input : {}, workdir(session)) || { tool: String(tool || 'tool'), action: `use ${tool}`, detail: '' };
294
+ const turn = session.turns.at(-1);
295
+ if ((session.grants || []).some((g) => sameAction(g, p))) {
296
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `Allowed (always in this task): ${p.action}` });
297
+ return { decision: 'allow' };
298
+ }
299
+ const id = this.ask(session, {
300
+ question: `Allow ${adapter.name} to ${p.action}?`,
301
+ context: p.why || '',
302
+ options: [APPROVAL.allow, APPROVAL.always, APPROVAL.deny],
303
+ permission: { tool: p.tool, action: p.action, detail: p.detail },
304
+ });
305
+ return { id };
306
+ }
307
+
243
308
  answer(session, text) {
244
309
  const ask = session.ask;
245
310
  if (!ask) return false;
246
311
  ask.answer = text;
247
- this.answers.set(ask.id, text);
312
+ const turn = session.turns.at(-1);
313
+ let label = `You answered: ${text}`;
314
+ if (ask.permission) {
315
+ const d = approvalDecision(text);
316
+ this.answers.set(ask.id, { answer: text, ...d });
317
+ const p = ask.permission;
318
+ if (d.always) (session.grants ||= []).push({ tool: p.tool, detail: p.detail });
319
+ // Denied here, so the turn's "blocked" card shouldn't offer it again.
320
+ if (d.decision === 'deny' && turn) (turn.denied ||= []).push({ tool: p.tool, detail: p.detail });
321
+ label = d.decision === 'allow' ? `You allowed${d.always ? ' (always in this task)' : ''}: ${p.action}` : `You denied: ${p.action}${d.message ? ` (“${d.message}”)` : ''}`;
322
+ } else this.answers.set(ask.id, { answer: text });
248
323
  delete session.ask;
249
324
  session.state = this.live.has(session.id) ? 'running' : 'review';
250
- const turn = session.turns.at(-1);
251
- if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `You answered: ${text}` });
325
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label });
252
326
  this.#touch(session, { turn: turn?.id });
253
327
  return true;
254
328
  }
255
329
 
330
+ // { answer } once answered (plus { decision, message? } for an approval), { pending }, or { cancelled }.
256
331
  pollAnswer(askId) {
257
332
  if (this.answers.has(askId)) {
258
- const answer = this.answers.get(askId);
333
+ const out = this.answers.get(askId);
259
334
  this.answers.delete(askId);
260
- return { answer };
335
+ return out;
261
336
  }
262
337
  const open = this.store.all().some((s) => s.ask?.id === askId);
263
338
  return open ? { pending: true } : { cancelled: true };
@@ -354,7 +429,7 @@ export class Runner extends EventEmitter {
354
429
  }
355
430
 
356
431
  // The record of a new turn, with the checkpoint taken before the agent touches anything.
357
- #newTurn(session, prompt, attachments) {
432
+ #newTurn(session, prompt, attachments, mode = session.mode) {
358
433
  const id = newId();
359
434
  // A subagent's edits land in the lead's turn, whose checkpoint already covers them.
360
435
  const checkpoint =
@@ -369,7 +444,7 @@ export class Runner extends EventEmitter {
369
444
  agent: session.agent,
370
445
  model: session.model,
371
446
  effort: session.effort || '',
372
- mode: session.mode,
447
+ mode,
373
448
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
374
449
  startedAt: Date.now(),
375
450
  endedAt: null,
@@ -404,14 +479,18 @@ export class Runner extends EventEmitter {
404
479
  WISP_URL: this.bridge.url,
405
480
  WISP_TOKEN: this.bridge.token,
406
481
  WISP_SESSION: session.id,
482
+ ...(adapter.approveTool && { WISP_APPROVE: '1' }),
407
483
  ...(team.length && { WISP_TEAM: JSON.stringify(team.map((m) => ({ name: m.name, agent: adapters[m.agent]?.name || m.agent, model: m.model, effort: m.effort, use: m.use, count: m.count || 1, copies: copies(m) }))) }),
408
484
  },
409
485
  },
410
486
  };
411
487
  }
412
488
 
413
- #startTurn(session, prompt, attachments = []) {
414
- const turn = this.#newTurn(session, prompt, attachments);
489
+ // grants: harness rules allowing what the user approved, for this turn only. once: a mode for
490
+ // this turn only (an approved retry the harness can't scope runs in Full auto).
491
+ #startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
492
+ const mode = MODES.has(once) ? once : session.mode;
493
+ const turn = this.#newTurn(session, prompt, attachments, mode);
415
494
  session.turns.push(turn);
416
495
  session.state = 'running';
417
496
  session.unread = false;
@@ -427,12 +506,16 @@ export class Runner extends EventEmitter {
427
506
  const team = adapter.askTool !== null ? session.subagents || [] : [];
428
507
  // A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
429
508
  const handoff = session.parentId ? '' : handoffNote(session, session.agent, session.agentSessionId, (t) => adapters[t.agent]?.name || t.agent);
509
+ // Mentioned tasks: a lead reads them with wisp_session; a run without wisp's tools gets their reports.
510
+ const tools = !session.parentId && adapter.askTool !== null && this.bridge;
511
+ const text = agentPrompt(prompt, this.store, { tool: tools ? (adapter.askTool ?? 'mcp__wisp__ask_user').replace(/ask_user$/, 'wisp_session') : null, self: session.id });
430
512
  const handle = adapter.run({
431
- prompt: handoff ? `${handoff}\n\n${prompt}` : prompt,
513
+ prompt: handoff ? `${handoff}\n\n${text}` : text,
432
514
  attachments,
433
515
  cwd: workdir(session),
434
516
  resumeId: session.agentSessionId,
435
- mode: session.mode,
517
+ mode,
518
+ grants,
436
519
  model: session.model,
437
520
  effort: session.effort || '',
438
521
  systemPrompt: this.#systemPrompt(session, adapter, team),
@@ -467,7 +550,7 @@ export class Runner extends EventEmitter {
467
550
  const next = session.queue.shift();
468
551
  if (next?.mode) session.mode = next.mode;
469
552
  if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
470
- if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
553
+ if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments, next);
471
554
  else this.#touch(session);
472
555
  };
473
556
  handle.release = release;
@@ -516,6 +599,14 @@ export class Runner extends EventEmitter {
516
599
  if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
517
600
  return;
518
601
  }
602
+ case 'blocked': {
603
+ // A tool call the harness's permissions refused. The card after the turn offers to retry it.
604
+ const item = { tool: String(ev.tool || 'tool'), detail: String(ev.detail ?? ''), ...(ev.rule !== undefined && { rule: ev.rule }) };
605
+ const list = (turn.blocked ||= []);
606
+ if (!list.some((b) => sameAction(b, item))) list.push(item);
607
+ this.store.save(session);
608
+ return;
609
+ }
519
610
  case 'background': {
520
611
  turn.background = ev.tasks;
521
612
  if (turn.interim) this.#waitItem(session, turn);
@@ -583,6 +674,7 @@ export class Runner extends EventEmitter {
583
674
  // Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
584
675
  if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
585
676
  }
677
+ if (!ev.stopped) blockedCard(turn);
586
678
  session.state = 'review';
587
679
  session.unread = !interrupted;
588
680
  this.store.save(session, { now: true });
@@ -601,3 +693,27 @@ export class Runner extends EventEmitter {
601
693
  if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
602
694
  }
603
695
  }
696
+
697
+ // What the turn's permissions refused, as one "blocked" block after the hero: the harness's own
698
+ // reports (which can carry a rule to allow exactly that) merged with the agent's "blocked" block,
699
+ // minus anything the user already denied. turn.blocked keeps the list for the retry.
700
+ function blockedCard(turn) {
701
+ const blocks = turn.presentation?.blocks || [];
702
+ const own = blocks.filter((b) => b.type === 'blocked').flatMap((b) => (Array.isArray(b.items) ? b.items : []));
703
+ const items = [...(turn.blocked || [])];
704
+ for (const i of own) {
705
+ if (!i || typeof i !== 'object') continue;
706
+ const item = { tool: String(i.tool || 'tool'), detail: String(i.detail ?? i.command ?? i.path ?? ''), ...(i.why && { why: String(i.why) }) };
707
+ const known = items.find((b) => sameAction(b, item) || (item.detail && b.detail === item.detail));
708
+ if (known) known.why ||= item.why;
709
+ else items.push(item);
710
+ }
711
+ // The agent may name a tool its own way (shell for Bash): the same command still counts.
712
+ const left = items.filter((b) => !(turn.denied || []).some((d) => sameAction(d, b) || (b.detail && d.detail === b.detail))).slice(0, 12);
713
+ if (left.length) turn.blocked = left;
714
+ else delete turn.blocked;
715
+ if (!turn.presentation) return;
716
+ const rest = blocks.filter((b) => b.type !== 'blocked');
717
+ if (left.length) rest.splice(rest[0]?.type === 'hero' ? 1 : 0, 0, { type: 'blocked', items: left.map(({ rule, ...b }) => b) });
718
+ turn.presentation.blocks = rest;
719
+ }
@@ -33,7 +33,7 @@ export function createLaunch(app) {
33
33
  if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
34
34
  return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
35
35
  });
36
- const mode = MODES.has(body.mode) ? body.mode : 'edits';
36
+ const mode = MODES.has(body.mode) ? body.mode : 'auto';
37
37
  // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
38
38
  // runs need one each, or the harnesses would edit the same files.
39
39
  const comparing = runners.length > 1;
@@ -27,7 +27,7 @@ export function phoneActions(app) {
27
27
  const d = store.config.defaults;
28
28
  const agent = d.agent || 'claude';
29
29
  const model = d.models?.[agent] ?? d.model ?? '';
30
- const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'edits' }, { kind: 'telegram', name: 'Telegram' });
30
+ const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'auto' }, { kind: 'telegram', name: 'Telegram' });
31
31
  app.broadcast('config', {});
32
32
  return s;
33
33
  },
package/server/store.js CHANGED
@@ -9,7 +9,7 @@ import { writeJsonAtomic } from './util.js';
9
9
 
10
10
  const DEFAULT_CONFIG = {
11
11
  projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
12
- defaults: { agent: 'auto', mode: 'edits', model: '', effort: '' }, // auto: wisp picks harness and model (server/router.js)
12
+ defaults: { agent: 'auto', mode: 'auto', model: '', effort: '' }, // agent auto: wisp picks harness and model (server/router.js)
13
13
  recipes: [], // saved prompts with {params}: server/recipes.js
14
14
  triggers: [], // schedules and GitHub watchers: server/triggers.js
15
15
  teams: [], // saved subagent presets: server/subagents.js
@@ -66,9 +66,15 @@ export class Store {
66
66
  cfg.defaults.effort = '';
67
67
  cfg.autoIntroduced = true;
68
68
  }
69
+ // Full auto became the default mode: switch once from Edits (the old default); Plan and
70
+ // any later pick stick.
71
+ if (!cfg.fullAutoIntroduced) {
72
+ if (cfg.defaults.mode === 'edits') cfg.defaults.mode = 'auto';
73
+ cfg.fullAutoIntroduced = true;
74
+ }
69
75
  return cfg;
70
76
  } catch {
71
- return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true };
77
+ return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true, fullAutoIntroduced: true };
72
78
  }
73
79
  }
74
80
 
@@ -100,7 +100,7 @@ export function normalizeTrigger(body, prev = null, { recipes = [] } = {}) {
100
100
  if (kind === 'schedule') parseCron(cron);
101
101
  const label = kind === 'issues' ? String(v('label', 'wisp')).trim() : '';
102
102
  if (kind === 'issues' && !label) throw bad('Pick the issue label to watch.');
103
- const mode = String(v('mode', kind === 'issues' ? 'plan' : 'edits'));
103
+ const mode = String(v('mode', kind === 'issues' ? 'plan' : 'auto'));
104
104
  if (!MODES.has(mode)) throw bad('mode must be plan, edits or auto.');
105
105
  const changedSource = prev && (kind !== prev.kind || cwd !== prev.cwd || label !== prev.label || String(v('branch')) !== prev.branch);
106
106
  return {