wisp-agents 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
package/server/runner.js CHANGED
@@ -4,23 +4,44 @@
4
4
  // setting they wait until the run finishes.
5
5
 
6
6
  import { EventEmitter } from 'node:events';
7
- import { randomUUID } from 'node:crypto';
8
7
  import path from 'node:path';
9
8
  import { fileURLToPath } from 'node:url';
10
9
  import { adapters } from './adapters/index.js';
11
10
  import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
12
11
  import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
13
12
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
13
+ import { handoffNote } from './handoff.js';
14
+ import { agentPrompt, plainMentions } from './mentions.js';
15
+ import { bad, MODES, newId } from './util.js';
14
16
 
15
17
  const MAX_ACTIVITY = 400;
16
- const MODES = new Set(['plan', 'edits', 'auto']);
17
18
  const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
18
19
  const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
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;
22
26
 
23
- export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
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
+ }
43
+
44
+ export { newId };
24
45
 
25
46
  // Where the agent actually works: its own worktree when it has one, else the project folder.
26
47
  export const workdir = (s) => s.worktree?.cwd || s.cwd;
@@ -32,8 +53,18 @@ function workspaceNote(s) {
32
53
  return `\n\n## Workspace\n\nYou are in an isolated git worktree (${wt.path}) on branch \`${wt.branch}\`, created from \`${wt.baseRef}\` for this task only. Other agents may be working in the user's main checkout, so make every change here. Dependencies and build output (node_modules, virtualenvs, dist/) are not shared with the main checkout: install them if a command needs them. Leave your changes uncommitted unless the user asks you to commit; the user ships them from wisp (commit, push, pull request).`;
33
54
  }
34
55
 
56
+ // A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
57
+ // Fields left out keep the session's; a new harness without a model gets its default.
58
+ function changedRun(session, run) {
59
+ if (!run || typeof run !== 'object') return null;
60
+ const agent = run.agent || session.agent;
61
+ const same = agent === session.agent;
62
+ const next = { agent, model: String(run.model ?? (same ? session.model : '') ?? ''), effort: String(run.effort ?? (same ? session.effort : '') ?? '') };
63
+ return same && next.model === (session.model || '') && next.effort === (session.effort || '') ? null : next;
64
+ }
65
+
35
66
  function autoTitle(prompt) {
36
- const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
67
+ const line = plainMentions(prompt).trim().split('\n')[0].replace(/\s+/g, ' ');
37
68
  return line.length > 64 ? `${line.slice(0, 63)}…` : line;
38
69
  }
39
70
 
@@ -68,6 +99,7 @@ export function summarize(s, store) {
68
99
  live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
69
100
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
70
101
  question: s.ask?.question || '',
102
+ approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
71
103
  branch: s.worktree?.branch || '',
72
104
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
73
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,
@@ -82,7 +114,7 @@ export function summarize(s, store) {
82
114
  ),
83
115
  ...(s.parentId && { parentId: s.parentId, member: s.member }),
84
116
  // Auto: what it picked and why, for the card's tooltip.
85
- route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives } : null,
117
+ route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives, ...(s.route.switched && { switched: true }) } : null,
86
118
  };
87
119
  }
88
120
 
@@ -112,7 +144,7 @@ export class Runner extends EventEmitter {
112
144
  }
113
145
 
114
146
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
115
- 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 }) {
116
148
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
117
149
  const now = Date.now();
118
150
  const session = {
@@ -129,6 +161,7 @@ export class Runner extends EventEmitter {
129
161
  createdAt: now,
130
162
  updatedAt: now,
131
163
  agentSessionId: null,
164
+ agentSessions: {}, // harness -> its own conversation id, so switching back resumes it
132
165
  queue: [],
133
166
  turns: [],
134
167
  ...(worktree && { worktree }),
@@ -145,10 +178,14 @@ export class Runner extends EventEmitter {
145
178
  }
146
179
 
147
180
  // mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
148
- // follow-up keeps its own, so the run before it isn't affected.
149
- reply(session, text, attachments = [], { queue = false, mode } = {}) {
181
+ // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
182
+ // switches the harness, model and thinking level the same way (see #switchRun).
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 } = {}) {
150
185
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
151
- if (session.ask && !mode) {
186
+ run = changedRun(session, run);
187
+ const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
188
+ if (session.ask && !Object.keys(extra).length) {
152
189
  this.answer(session, text);
153
190
  return { answered: true };
154
191
  }
@@ -158,27 +195,65 @@ export class Runner extends EventEmitter {
158
195
  // The run already produced its result and the CLI is only lingering on its way out:
159
196
  // there's nothing to wait for, so let it go and start the follow-up now.
160
197
  if (turn?.status !== 'running') {
161
- session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
198
+ session.queue.push({ id: newId(), text, attachments, ...extra });
162
199
  handle.release();
163
200
  return { queued: session.queue.length > 0 };
164
201
  }
165
202
  // Interrupting needs an agent session to resume; before the agent reports one, queue.
166
203
  if (queue || !session.agentSessionId) {
167
- session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
204
+ session.queue.push({ id: newId(), text, attachments, ...extra });
168
205
  this.#touch(session);
169
206
  return { queued: true, ahead: session.queue.length - 1 };
170
207
  }
171
208
  // Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
172
209
  const at = session.queue.findIndex((q) => !q.now);
173
- session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...(mode && { mode }) });
210
+ session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...extra });
174
211
  this.#interrupt(session);
175
212
  return { interrupted: true };
176
213
  }
177
214
  if (mode) session.mode = mode;
178
- this.#startTurn(session, text, attachments);
215
+ if (run) this.#switchRun(session, run);
216
+ this.#startTurn(session, text, attachments, extra);
179
217
  return { queued: false };
180
218
  }
181
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
+
239
+ // A follow-up on another harness, model or thinking level. A model or level change resumes the
240
+ // same conversation; a harness change resumes that harness's own one (if it ran earlier in this
241
+ // task) or starts it fresh, and #startTurn hands it the turns it missed.
242
+ #switchRun(session, { agent, model, effort }) {
243
+ session.agentSessions ||= {};
244
+ // Sessions from before agentSessions: their conversation belongs to the harness they ran on.
245
+ if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
246
+ for (const t of session.turns) t.agent ||= session.agent;
247
+ // Auto's pick was overruled: the card stops crediting it, and the router learns (services/auto.js).
248
+ if (session.route && (agent !== session.agent || model !== session.model)) session.route.switched = true;
249
+ if (agent !== session.agent) {
250
+ session.agent = agent;
251
+ session.agentSessionId = session.agentSessions[agent] || null;
252
+ }
253
+ session.model = model;
254
+ session.effort = effort;
255
+ }
256
+
182
257
  // Drop a follow-up that hasn't gone out yet.
183
258
  unqueue(session, itemId) {
184
259
  const at = session.queue.findIndex((q) => q.id === itemId);
@@ -189,7 +264,8 @@ export class Runner extends EventEmitter {
189
264
  }
190
265
 
191
266
  // ask_user: the agent is blocked inside a tool call until answer() is called.
192
- ask(session, { question, context, options }) {
267
+ // permission: set for a live approval ({ tool, action, detail }), see requestApproval().
268
+ ask(session, { question, context, options, permission }) {
193
269
  const turn = session.turns.at(-1);
194
270
  session.ask = {
195
271
  id: newId(),
@@ -197,34 +273,66 @@ export class Runner extends EventEmitter {
197
273
  context: context ? String(context) : '',
198
274
  options: Array.isArray(options) ? options.map(String).slice(0, 6) : [],
199
275
  askedAt: Date.now(),
276
+ ...(permission && { permission }),
200
277
  };
201
278
  session.state = 'waiting';
202
279
  session.unread = true;
203
280
  delete session.archived; // a question needs the user, so it comes back to the feed
204
- 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 });
205
283
  this.#touch(session, { turn: turn?.id });
206
284
  this.emit('finished', { session: summarize(session, this.store) });
207
285
  return session.ask.id;
208
286
  }
209
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
+
210
308
  answer(session, text) {
211
309
  const ask = session.ask;
212
310
  if (!ask) return false;
213
311
  ask.answer = text;
214
- 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 });
215
323
  delete session.ask;
216
324
  session.state = this.live.has(session.id) ? 'running' : 'review';
217
- const turn = session.turns.at(-1);
218
- 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 });
219
326
  this.#touch(session, { turn: turn?.id });
220
327
  return true;
221
328
  }
222
329
 
330
+ // { answer } once answered (plus { decision, message? } for an approval), { pending }, or { cancelled }.
223
331
  pollAnswer(askId) {
224
332
  if (this.answers.has(askId)) {
225
- const answer = this.answers.get(askId);
333
+ const out = this.answers.get(askId);
226
334
  this.answers.delete(askId);
227
- return { answer };
335
+ return out;
228
336
  }
229
337
  const open = this.store.all().some((s) => s.ask?.id === askId);
230
338
  return open ? { pending: true } : { cancelled: true };
@@ -234,9 +342,9 @@ export class Runner extends EventEmitter {
234
342
  // created on first use and resumed after that. Returns one record (or { error }) per task.
235
343
  delegate(lead, tasks) {
236
344
  const turn = lead.turns.at(-1);
237
- if (turn?.status !== 'running') throw Object.assign(new Error('The lead agent is not running.'), { status: 409 });
345
+ if (turn?.status !== 'running') throw bad('The lead agent is not running.', 409);
238
346
  const team = lead.subagents || [];
239
- if (!team.length) throw Object.assign(new Error('This session has no subagents. The user can add them when launching.'), { status: 400 });
347
+ if (!team.length) throw bad('This session has no subagents. The user can add them when launching.');
240
348
  const out = [];
241
349
  const claimed = new Set(); // copies handed a task in this call
242
350
  const busy = (name) => claimed.has(name) || this.live.has(lead.children?.[name]);
@@ -320,7 +428,8 @@ export class Runner extends EventEmitter {
320
428
  this.live.get(session.id).stop();
321
429
  }
322
430
 
323
- #startTurn(session, prompt, attachments = []) {
431
+ // The record of a new turn, with the checkpoint taken before the agent touches anything.
432
+ #newTurn(session, prompt, attachments, mode = session.mode) {
324
433
  const id = newId();
325
434
  // A subagent's edits land in the lead's turn, whose checkpoint already covers them.
326
435
  const checkpoint =
@@ -329,13 +438,13 @@ export class Runner extends EventEmitter {
329
438
  : !session.turns.length && session.worktree?.head
330
439
  ? freshCheckpoint(session.worktree)
331
440
  : captureCheckpoint(workdir(session), session.id, id);
332
- const turn = {
441
+ return {
333
442
  id,
334
443
  prompt,
335
444
  agent: session.agent,
336
445
  model: session.model,
337
446
  effort: session.effort || '',
338
- mode: session.mode,
447
+ mode,
339
448
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
340
449
  startedAt: Date.now(),
341
450
  endedAt: null,
@@ -346,6 +455,42 @@ export class Runner extends EventEmitter {
346
455
  presentation: null,
347
456
  stats: null,
348
457
  };
458
+ }
459
+
460
+ // The wisp protocol for this run: a lead hears about its team, a subagent that it can't ask.
461
+ #systemPrompt(session, adapter, team) {
462
+ const base = session.parentId
463
+ ? systemPrompt({ ask: SUBAGENT_ASK })
464
+ : systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
465
+ return base + workspaceNote(session) + this.promptExtra(session);
466
+ }
467
+
468
+ // wisp's own MCP server (ask_user, delegate), for harnesses that take one. Subagents get none.
469
+ #mcpServers(session, adapter, team) {
470
+ if (!this.bridge || adapter.askTool === null || session.parentId) return {};
471
+ return {
472
+ wisp: {
473
+ command: process.execPath,
474
+ args: [ASK_SERVER],
475
+ // Inside the desktop app execPath is the Electron binary; without
476
+ // ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
477
+ env: {
478
+ ELECTRON_RUN_AS_NODE: '1',
479
+ WISP_URL: this.bridge.url,
480
+ WISP_TOKEN: this.bridge.token,
481
+ WISP_SESSION: session.id,
482
+ ...(adapter.approveTool && { WISP_APPROVE: '1' }),
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) }))) }),
484
+ },
485
+ },
486
+ };
487
+ }
488
+
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);
349
494
  session.turns.push(turn);
350
495
  session.state = 'running';
351
496
  session.unread = false;
@@ -359,37 +504,24 @@ export class Runner extends EventEmitter {
359
504
  const adapter = adapters[session.agent];
360
505
  // Leads get the delegate tool next to ask_user; subagents get neither (nobody to ask).
361
506
  const team = adapter.askTool !== null ? session.subagents || [] : [];
362
- const prompt0 = session.parentId
363
- ? systemPrompt({ ask: SUBAGENT_ASK })
364
- : systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
507
+ // A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
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 });
365
512
  const handle = adapter.run({
366
- prompt,
513
+ prompt: handoff ? `${handoff}\n\n${text}` : text,
367
514
  attachments,
368
515
  cwd: workdir(session),
369
516
  resumeId: session.agentSessionId,
370
- mode: session.mode,
517
+ mode,
518
+ grants,
371
519
  model: session.model,
372
520
  effort: session.effort || '',
373
- systemPrompt: prompt0 + workspaceNote(session) + this.promptExtra(session),
521
+ systemPrompt: this.#systemPrompt(session, adapter, team),
374
522
  // A lead with a team delegates through wisp, so the harness's own subagent tool is switched off.
375
523
  delegating: team.length > 0 && !!this.bridge,
376
- mcpServers: this.bridge && adapter.askTool !== null && !session.parentId
377
- ? {
378
- wisp: {
379
- command: process.execPath,
380
- args: [ASK_SERVER],
381
- // Inside the desktop app execPath is the Electron binary; without
382
- // ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
383
- env: {
384
- ELECTRON_RUN_AS_NODE: '1',
385
- WISP_URL: this.bridge.url,
386
- WISP_TOKEN: this.bridge.token,
387
- WISP_SESSION: session.id,
388
- ...(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) }))) }),
389
- },
390
- },
391
- }
392
- : {},
524
+ mcpServers: this.#mcpServers(session, adapter, team),
393
525
  onEvent: (ev) => {
394
526
  if (released) return; // a lingering process we already let go of
395
527
  const waiting = this.#onEvent(session, turn, ev) === 'waiting';
@@ -417,7 +549,8 @@ export class Runner extends EventEmitter {
417
549
  this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
418
550
  const next = session.queue.shift();
419
551
  if (next?.mode) session.mode = next.mode;
420
- if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
552
+ if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
553
+ if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments, next);
421
554
  else this.#touch(session);
422
555
  };
423
556
  handle.release = release;
@@ -429,6 +562,7 @@ export class Runner extends EventEmitter {
429
562
  switch (ev.type) {
430
563
  case 'session':
431
564
  session.agentSessionId = ev.id;
565
+ (session.agentSessions ||= {})[session.agent] = ev.id;
432
566
  return;
433
567
  case 'tool':
434
568
  case 'tool_error':
@@ -465,6 +599,14 @@ export class Runner extends EventEmitter {
465
599
  if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
466
600
  return;
467
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
+ }
468
610
  case 'background': {
469
611
  turn.background = ev.tasks;
470
612
  if (turn.interim) this.#waitItem(session, turn);
@@ -532,6 +674,7 @@ export class Runner extends EventEmitter {
532
674
  // Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
533
675
  if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
534
676
  }
677
+ if (!ev.stopped) blockedCard(turn);
535
678
  session.state = 'review';
536
679
  session.unread = !interrupted;
537
680
  this.store.save(session, { now: true });
@@ -550,3 +693,27 @@ export class Runner extends EventEmitter {
550
693
  if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
551
694
  }
552
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
+ }
@@ -0,0 +1,67 @@
1
+ // Auto: wisp picks harness and model per task and learns from outcomes (server/router.js).
2
+ // This wires the router to the rest of the server: which harnesses are installed here, the
3
+ // user's Auto settings, and the outcome signals that come back from summaries.
4
+
5
+ import { listAgents } from '../adapters/index.js';
6
+ import { GOALS } from '../router.js';
7
+ import { bad } from '../util.js';
8
+
9
+ export function createAuto(app) {
10
+ const { store, router } = app;
11
+ let agentsHere = [];
12
+
13
+ const detect = async (opts) => {
14
+ const agents = await listAgents(opts);
15
+ agentsHere = agents;
16
+ const added = router.sync(agents);
17
+ if (added.length) app.broadcast('router', { added });
18
+ return agents;
19
+ };
20
+ // This computer's harnesses: the last ones detected, else detected now.
21
+ const agents = async () => (agentsHere.length ? agentsHere : await detect());
22
+
23
+ const opts = () => {
24
+ const r = store.config.defaults.router || {};
25
+ return { explore: r.explore !== 'off', allowed: Array.isArray(r.allowed) && r.allowed.length ? r.allowed : null };
26
+ };
27
+ const goalOf = (model) => (GOALS[model] ? model : GOALS[store.config.defaults.models?.auto] ? store.config.defaults.models.auto : 'balanced');
28
+
29
+ // Subagents set to Auto are routed on their first task, from this computer's harnesses.
30
+ app.runner.routeAuto = (prompt) => router.choose({ prompt, agents: agentsHere, goal: goalOf(), ...opts() });
31
+
32
+ // A routed session's outcome so far, from its summary: works the same for sessions on machines.
33
+ // Follow-ups after the first turn count as corrections; a merged or closed PR settles it, unless
34
+ // the user moved the task to another harness or model first.
35
+ const signals = (sum) => {
36
+ if (!sum?.id || !router.route(sum.id) || sum.status === 'running' || sum.state === 'running') return;
37
+ const pr = sum.pr?.state;
38
+ router.signal(sum.id, {
39
+ hero: ['done', 'partial', 'blocked', 'info'].includes(sum.status) ? sum.status : undefined,
40
+ followups: Math.max(0, (sum.turnCount || 1) - 1),
41
+ merged: pr === 'MERGED' || undefined,
42
+ closed: pr === 'CLOSED' || undefined,
43
+ switched: sum.route?.switched || undefined,
44
+ });
45
+ };
46
+
47
+ // agent 'auto' (and compare entries set to it) → a harness and model picked by the router from
48
+ // `agents`: this computer's, or the machine's the launch goes to. Model is the goal for Auto.
49
+ const resolve = async (body, agents, { team = false } = {}) => {
50
+ const want = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])];
51
+ if (!want.some((r) => r.agent === 'auto')) return body;
52
+ const taken = [];
53
+ const out = want.map((r, i) => {
54
+ if (r.agent !== 'auto') return r;
55
+ const pick = router.choose({ prompt: body.prompt, attachments: (body.attachments || []).length, agents, goal: goalOf(r.model), important: !!body.important, exclude: taken, needDelegate: i === 0 && team, ...opts() });
56
+ if (!pick) throw bad('Auto found no installed harness to run this on.');
57
+ taken.push(pick.key);
58
+ return { agent: pick.agent, model: pick.model, effort: '', route: pick.route };
59
+ });
60
+ return { ...body, ...out[0], compare: out.slice(1) };
61
+ };
62
+
63
+ // What the Auto view shows: goals, settings, and what the router learned.
64
+ const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: opts().explore, allowed: opts().allowed }, ...router.report(await agents()) });
65
+
66
+ return { detect, agents, opts, goalOf, signals, resolve, view };
67
+ }
@@ -0,0 +1,53 @@
1
+ // Feedback for the wisp team: thumbs on a turn and/or a note.
2
+ // A rating sticks to its turn (it shows on the card and can be taken back). What goes out is
3
+ // the rating, the user's own words, and how the run was set up — never the prompt or the result.
4
+
5
+ import { splitId } from '../machines.js';
6
+ import { feedbackAvailable } from '../telemetry.js';
7
+ import { bad } from '../util.js';
8
+ import { runProps } from './telemetry.js';
9
+
10
+ export function createFeedback(app) {
11
+ const { store, machines, telemetry } = app;
12
+
13
+ return async function sendFeedback({ rating, text, sessionId, turnId, source } = {}) {
14
+ if (rating !== undefined && rating !== null && rating !== 'up' && rating !== 'down') throw bad('rating must be up or down');
15
+ text = String(text || '').trim();
16
+ if (rating === undefined && !text) throw bad('Write something, or pick 👍 / 👎.');
17
+ // A session on a machine: that wisp keeps the rating on its turn and sends it.
18
+ const at = sessionId && splitId(sessionId);
19
+ if (sessionId && turnId && rating !== undefined) app.router.signal(sessionId, { feedback: rating });
20
+ if (at) {
21
+ try {
22
+ return await machines.json(at.machine, 'POST', '/feedback', { rating, text, sessionId: at.id, turnId, source });
23
+ } catch (err) {
24
+ if (err.status === 400) throw err; // offline, or an older wisp there: send it from here, without the run
25
+ }
26
+ }
27
+ const s = sessionId && !at ? store.get(sessionId) : null;
28
+ if (sessionId && !at && !s) throw bad('no such session', 404);
29
+ const turn = s && (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
30
+ if (turnId && s && !turn) throw bad('no such turn', 404);
31
+ // Only thumbs on a turn stick to it; a rating in the feedback dialog is about wisp as a whole.
32
+ if (turn && turnId && rating !== undefined) {
33
+ turn.feedback = rating ? { rating, at: Date.now() } : undefined;
34
+ store.save(s);
35
+ app.broadcast('turn', { sessionId: s.id, turnId: turn.id });
36
+ }
37
+ const blocks = turn?.presentation?.blocks || [];
38
+ const context = turn && {
39
+ ...runProps(s, turn),
40
+ status: turn.status,
41
+ heroStatus: blocks.find((b) => b.type === 'hero')?.status,
42
+ blockTypes: [...new Set(blocks.map((b) => b.type))],
43
+ durationMs: turn.endedAt ? turn.endedAt - turn.startedAt : undefined,
44
+ tour: s.agent === 'wisp' || undefined,
45
+ };
46
+ const props = { ...context, rating: rating || undefined, source: String(source || (turn ? 'turn' : 'app')).slice(0, 20), machine: at ? true : undefined };
47
+ // Thumbs on a turn count as a rating; anything written, or sent from the dialog, is 'feedback'.
48
+ const event = text || !turnId ? 'feedback' : 'turn_rated';
49
+ if (event === 'turn_rated' && rating === null) props.cleared = true;
50
+ const sent = await telemetry.feedback(event, { ...props, text: text || undefined });
51
+ return { ok: true, sent, available: feedbackAvailable() };
52
+ };
53
+ }