wisp-agents 0.14.0 → 0.16.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 (114) hide show
  1. package/CHANGELOG.md +50 -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 +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -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-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5232
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1901
package/server/runner.js CHANGED
@@ -4,23 +4,23 @@
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 { bad, MODES, newId } from './util.js';
14
15
 
15
16
  const MAX_ACTIVITY = 400;
16
- const MODES = new Set(['plan', 'edits', 'auto']);
17
17
  const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
18
18
  const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
19
19
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
20
20
  // The delegate tool lives on the same injected server, so harnesses name it like ask_user.
21
21
  const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
22
22
 
23
- export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
23
+ export { newId };
24
24
 
25
25
  // Where the agent actually works: its own worktree when it has one, else the project folder.
26
26
  export const workdir = (s) => s.worktree?.cwd || s.cwd;
@@ -32,6 +32,16 @@ function workspaceNote(s) {
32
32
  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
33
  }
34
34
 
35
+ // A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
36
+ // Fields left out keep the session's; a new harness without a model gets its default.
37
+ function changedRun(session, run) {
38
+ if (!run || typeof run !== 'object') return null;
39
+ const agent = run.agent || session.agent;
40
+ const same = agent === session.agent;
41
+ const next = { agent, model: String(run.model ?? (same ? session.model : '') ?? ''), effort: String(run.effort ?? (same ? session.effort : '') ?? '') };
42
+ return same && next.model === (session.model || '') && next.effort === (session.effort || '') ? null : next;
43
+ }
44
+
35
45
  function autoTitle(prompt) {
36
46
  const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
37
47
  return line.length > 64 ? `${line.slice(0, 63)}…` : line;
@@ -82,7 +92,7 @@ export function summarize(s, store) {
82
92
  ),
83
93
  ...(s.parentId && { parentId: s.parentId, member: s.member }),
84
94
  // 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,
95
+ 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
96
  };
87
97
  }
88
98
 
@@ -129,6 +139,7 @@ export class Runner extends EventEmitter {
129
139
  createdAt: now,
130
140
  updatedAt: now,
131
141
  agentSessionId: null,
142
+ agentSessions: {}, // harness -> its own conversation id, so switching back resumes it
132
143
  queue: [],
133
144
  turns: [],
134
145
  ...(worktree && { worktree }),
@@ -145,10 +156,13 @@ export class Runner extends EventEmitter {
145
156
  }
146
157
 
147
158
  // 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 } = {}) {
159
+ // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
160
+ // switches the harness, model and thinking level the same way (see #switchRun).
161
+ reply(session, text, attachments = [], { queue = false, mode, run } = {}) {
150
162
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
151
- if (session.ask && !mode) {
163
+ run = changedRun(session, run);
164
+ const extra = { ...(mode && { mode }), ...(run && { run }) };
165
+ if (session.ask && !mode && !run) {
152
166
  this.answer(session, text);
153
167
  return { answered: true };
154
168
  }
@@ -158,27 +172,46 @@ export class Runner extends EventEmitter {
158
172
  // The run already produced its result and the CLI is only lingering on its way out:
159
173
  // there's nothing to wait for, so let it go and start the follow-up now.
160
174
  if (turn?.status !== 'running') {
161
- session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
175
+ session.queue.push({ id: newId(), text, attachments, ...extra });
162
176
  handle.release();
163
177
  return { queued: session.queue.length > 0 };
164
178
  }
165
179
  // Interrupting needs an agent session to resume; before the agent reports one, queue.
166
180
  if (queue || !session.agentSessionId) {
167
- session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
181
+ session.queue.push({ id: newId(), text, attachments, ...extra });
168
182
  this.#touch(session);
169
183
  return { queued: true, ahead: session.queue.length - 1 };
170
184
  }
171
185
  // Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
172
186
  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 }) });
187
+ session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...extra });
174
188
  this.#interrupt(session);
175
189
  return { interrupted: true };
176
190
  }
177
191
  if (mode) session.mode = mode;
192
+ if (run) this.#switchRun(session, run);
178
193
  this.#startTurn(session, text, attachments);
179
194
  return { queued: false };
180
195
  }
181
196
 
197
+ // A follow-up on another harness, model or thinking level. A model or level change resumes the
198
+ // same conversation; a harness change resumes that harness's own one (if it ran earlier in this
199
+ // task) or starts it fresh, and #startTurn hands it the turns it missed.
200
+ #switchRun(session, { agent, model, effort }) {
201
+ session.agentSessions ||= {};
202
+ // Sessions from before agentSessions: their conversation belongs to the harness they ran on.
203
+ if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
204
+ for (const t of session.turns) t.agent ||= session.agent;
205
+ // Auto's pick was overruled: the card stops crediting it, and the router learns (services/auto.js).
206
+ if (session.route && (agent !== session.agent || model !== session.model)) session.route.switched = true;
207
+ if (agent !== session.agent) {
208
+ session.agent = agent;
209
+ session.agentSessionId = session.agentSessions[agent] || null;
210
+ }
211
+ session.model = model;
212
+ session.effort = effort;
213
+ }
214
+
182
215
  // Drop a follow-up that hasn't gone out yet.
183
216
  unqueue(session, itemId) {
184
217
  const at = session.queue.findIndex((q) => q.id === itemId);
@@ -234,9 +267,9 @@ export class Runner extends EventEmitter {
234
267
  // created on first use and resumed after that. Returns one record (or { error }) per task.
235
268
  delegate(lead, tasks) {
236
269
  const turn = lead.turns.at(-1);
237
- if (turn?.status !== 'running') throw Object.assign(new Error('The lead agent is not running.'), { status: 409 });
270
+ if (turn?.status !== 'running') throw bad('The lead agent is not running.', 409);
238
271
  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 });
272
+ if (!team.length) throw bad('This session has no subagents. The user can add them when launching.');
240
273
  const out = [];
241
274
  const claimed = new Set(); // copies handed a task in this call
242
275
  const busy = (name) => claimed.has(name) || this.live.has(lead.children?.[name]);
@@ -320,7 +353,8 @@ export class Runner extends EventEmitter {
320
353
  this.live.get(session.id).stop();
321
354
  }
322
355
 
323
- #startTurn(session, prompt, attachments = []) {
356
+ // The record of a new turn, with the checkpoint taken before the agent touches anything.
357
+ #newTurn(session, prompt, attachments) {
324
358
  const id = newId();
325
359
  // A subagent's edits land in the lead's turn, whose checkpoint already covers them.
326
360
  const checkpoint =
@@ -329,7 +363,7 @@ export class Runner extends EventEmitter {
329
363
  : !session.turns.length && session.worktree?.head
330
364
  ? freshCheckpoint(session.worktree)
331
365
  : captureCheckpoint(workdir(session), session.id, id);
332
- const turn = {
366
+ return {
333
367
  id,
334
368
  prompt,
335
369
  agent: session.agent,
@@ -346,6 +380,38 @@ export class Runner extends EventEmitter {
346
380
  presentation: null,
347
381
  stats: null,
348
382
  };
383
+ }
384
+
385
+ // The wisp protocol for this run: a lead hears about its team, a subagent that it can't ask.
386
+ #systemPrompt(session, adapter, team) {
387
+ const base = session.parentId
388
+ ? systemPrompt({ ask: SUBAGENT_ASK })
389
+ : systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
390
+ return base + workspaceNote(session) + this.promptExtra(session);
391
+ }
392
+
393
+ // wisp's own MCP server (ask_user, delegate), for harnesses that take one. Subagents get none.
394
+ #mcpServers(session, adapter, team) {
395
+ if (!this.bridge || adapter.askTool === null || session.parentId) return {};
396
+ return {
397
+ wisp: {
398
+ command: process.execPath,
399
+ args: [ASK_SERVER],
400
+ // Inside the desktop app execPath is the Electron binary; without
401
+ // ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
402
+ env: {
403
+ ELECTRON_RUN_AS_NODE: '1',
404
+ WISP_URL: this.bridge.url,
405
+ WISP_TOKEN: this.bridge.token,
406
+ WISP_SESSION: session.id,
407
+ ...(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
+ },
409
+ },
410
+ };
411
+ }
412
+
413
+ #startTurn(session, prompt, attachments = []) {
414
+ const turn = this.#newTurn(session, prompt, attachments);
349
415
  session.turns.push(turn);
350
416
  session.state = 'running';
351
417
  session.unread = false;
@@ -359,37 +425,20 @@ export class Runner extends EventEmitter {
359
425
  const adapter = adapters[session.agent];
360
426
  // Leads get the delegate tool next to ask_user; subagents get neither (nobody to ask).
361
427
  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'));
428
+ // A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
429
+ const handoff = session.parentId ? '' : handoffNote(session, session.agent, session.agentSessionId, (t) => adapters[t.agent]?.name || t.agent);
365
430
  const handle = adapter.run({
366
- prompt,
431
+ prompt: handoff ? `${handoff}\n\n${prompt}` : prompt,
367
432
  attachments,
368
433
  cwd: workdir(session),
369
434
  resumeId: session.agentSessionId,
370
435
  mode: session.mode,
371
436
  model: session.model,
372
437
  effort: session.effort || '',
373
- systemPrompt: prompt0 + workspaceNote(session) + this.promptExtra(session),
438
+ systemPrompt: this.#systemPrompt(session, adapter, team),
374
439
  // A lead with a team delegates through wisp, so the harness's own subagent tool is switched off.
375
440
  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
- : {},
441
+ mcpServers: this.#mcpServers(session, adapter, team),
393
442
  onEvent: (ev) => {
394
443
  if (released) return; // a lingering process we already let go of
395
444
  const waiting = this.#onEvent(session, turn, ev) === 'waiting';
@@ -417,6 +466,7 @@ export class Runner extends EventEmitter {
417
466
  this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
418
467
  const next = session.queue.shift();
419
468
  if (next?.mode) session.mode = next.mode;
469
+ if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
420
470
  if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
421
471
  else this.#touch(session);
422
472
  };
@@ -429,6 +479,7 @@ export class Runner extends EventEmitter {
429
479
  switch (ev.type) {
430
480
  case 'session':
431
481
  session.agentSessionId = ev.id;
482
+ (session.agentSessions ||= {})[session.agent] = ev.id;
432
483
  return;
433
484
  case 'tool':
434
485
  case 'tool_error':
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ // One-click setup for people with no harness yet. opencode is the one wisp installs: its free
2
+ // models work without an account, so a fresh computer can run a task right away. It goes into
3
+ // wisp's own folder (~/.wisp/tools) through npm, which every wisp install already has: no sudo,
4
+ // no shell profile edits, the same on Windows. The opencode adapter finds it there.
5
+
6
+ import fs from 'node:fs';
7
+ import { spawn } from '../proc.js';
8
+ import { bad, toolsDir } from '../util.js';
9
+
10
+ export const INSTALLABLE = {
11
+ opencode: { pkg: 'opencode-ai', name: 'opencode' },
12
+ };
13
+ const TIMEOUT_MS = 5 * 60_000;
14
+
15
+ // npm install --prefix <dir> <pkg>@latest. Resolves when it worked, rejects with the tail of
16
+ // npm's output when it didn't: a 424, since offline or a broken npm isn't a wisp bug (it's
17
+ // counted in the harness_install event instead of reported as an exception).
18
+ export function npmInstall(pkg, dir, { npm = process.env.WISP_NPM || 'npm', timeout = TIMEOUT_MS } = {}) {
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ const env = { ...process.env };
21
+ delete env.ELECTRON_RUN_AS_NODE; // the desktop app's node mode must not leak into npm
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(npm, ['install', '--prefix', dir, '--no-audit', '--no-fund', '--loglevel=error', `${pkg}@latest`], { env, stdio: ['ignore', 'pipe', 'pipe'] });
24
+ let out = '';
25
+ const keep = (d) => (out = (out + d).slice(-4000));
26
+ child.stdout.on('data', keep);
27
+ child.stderr.on('data', keep);
28
+ const timer = setTimeout(() => child.kill(), timeout);
29
+ child.on('error', (err) => (clearTimeout(timer), reject(bad(err.code === 'ENOENT' ? 'npm isn’t on PATH, so wisp can’t install it. Install Node.js (it comes with npm), then try again.' : err.message, 424))));
30
+ child.on('close', (code, signal) => {
31
+ clearTimeout(timer);
32
+ if (code === 0) return resolve();
33
+ const tail = out.trim().split('\n').filter(Boolean).slice(-3).join(' ');
34
+ reject(bad(`npm install ${pkg} ${signal ? 'timed out' : 'failed'}${tail ? `: ${tail}` : ''}`, 424));
35
+ });
36
+ });
37
+ }
38
+
39
+ export function createInstaller(app, { install = npmInstall } = {}) {
40
+ const running = new Map(); // id -> promise: a second click (or tab) waits for the same install
41
+
42
+ // Installs the harness, detects again and returns the harness list. Throws when the install
43
+ // failed or the harness still isn't found afterwards.
44
+ async function run(id) {
45
+ const spec = INSTALLABLE[id];
46
+ if (!spec) throw bad(`wisp can’t install ${id}. Install it yourself, then re-check.`);
47
+ if (!running.has(id)) {
48
+ const job = (async () => {
49
+ const started = Date.now();
50
+ try {
51
+ await install(spec.pkg, toolsDir());
52
+ } catch (err) {
53
+ app.telemetry?.track('harness_install', { agent: id, ok: false, ms: Date.now() - started });
54
+ throw err;
55
+ }
56
+ const agents = await app.auto.detect({ fresh: true });
57
+ const ok = agents.find((a) => a.id === id)?.available;
58
+ app.telemetry?.track('harness_install', { agent: id, ok: !!ok, ms: Date.now() - started });
59
+ if (!ok) throw bad(`${spec.name} was installed but doesn’t start: ${agents.find((a) => a.id === id)?.error || 'unknown error'}`, 500);
60
+ app.broadcast('agents', {});
61
+ return agents;
62
+ })().finally(() => running.delete(id));
63
+ running.set(id, job);
64
+ }
65
+ return running.get(id);
66
+ }
67
+
68
+ return { run, installing: (id) => running.has(id) };
69
+ }
@@ -0,0 +1,127 @@
1
+ // One way in for every launch (the launcher, the CLI, recipes, triggers, the phone and compare
2
+ // runs), here or on a machine, plus the follow-ups and "done" that every surface shares.
3
+
4
+ import fs from 'node:fs';
5
+ import { adapters } from '../adapters/index.js';
6
+ import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
7
+ import { qualify } from '../machines.js';
8
+ import { fill, positional } from '../recipes.js';
9
+ import { summarize } from '../runner.js';
10
+ import { normalizeTeam } from '../subagents.js';
11
+ import { bad, expand, MODES, newId } from '../util.js';
12
+
13
+ export const MAX_COMPARE = 4;
14
+
15
+ export function createLaunch(app) {
16
+ const { store, runner, machines } = app;
17
+
18
+ // body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
19
+ // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
20
+ async function launch(body, origin = null) {
21
+ if (!String(body.prompt || '').trim()) throw bad('prompt required');
22
+ const cwd = expand(body.cwd || process.cwd());
23
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
24
+ const d = store.config.defaults;
25
+ const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
26
+ if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
27
+ const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
28
+ // compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
29
+ // resolved here: the router picks a harness and model (a different one per compare run).
30
+ const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0 });
31
+ const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
32
+ const agent = r.agent || (i ? d.agent : '') || 'claude';
33
+ if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
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
+ });
36
+ const mode = MODES.has(body.mode) ? body.mode : 'edits';
37
+ // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
38
+ // runs need one each, or the harnesses would edit the same files.
39
+ const comparing = runners.length > 1;
40
+ const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
41
+ // groupId: one launch sent to several machines (or here and there) shows as one task.
42
+ const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
43
+ const attachments = app.uploads.resolve(body.attachments);
44
+ const ids = runners.map(() => newId());
45
+ let trees = ids.map(() => null);
46
+ if (wantTree) {
47
+ const must = body.worktree === true || (comparing && mode !== 'plan');
48
+ // One fetch for the whole launch, then the checkouts side by side: compare runs used to
49
+ // fetch and check out one after the other before anything showed up in the feed.
50
+ const results = await worktreeSource(cwd).then(
51
+ (source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
52
+ (err) => [{ status: 'rejected', reason: err }],
53
+ );
54
+ const failed = results?.find((x) => x.status === 'rejected')?.reason;
55
+ trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
56
+ if (must && (failed || trees.some((t) => !t))) {
57
+ await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
58
+ if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
59
+ throw bad(comparing ? 'Compare needs a git repo with at least one commit, so each harness gets its own worktree.' : 'Not a git repo with commits, so there is nothing to make a worktree from.');
60
+ }
61
+ if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
62
+ }
63
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
64
+ }
65
+
66
+ // Where a launch runs: body.machine, else a project folder that only exists on a machine
67
+ // (recipes and schedules keep just the path). null = here.
68
+ function machineFor(body) {
69
+ if (body.machine) return machines.get(String(body.machine)).id;
70
+ const cwd = String(body.cwd || '');
71
+ if (!cwd || fs.existsSync(expand(cwd))) return null;
72
+ return store.config.projects.find((p) => p.machine && (cwd === p.path || cwd.startsWith(`${p.path}/`)))?.machine || null;
73
+ }
74
+
75
+ // Attachments are uploaded here; a machine gets its own copies before a launch or reply.
76
+ async function pushUploads(mid, ids) {
77
+ const out = [];
78
+ for (const u of app.uploads.resolve(ids)) out.push((await machines.upload(mid, u)).id);
79
+ return out;
80
+ }
81
+
82
+ // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
83
+ async function launchAny(body, origin = null) {
84
+ const mid = machineFor(body);
85
+ if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
86
+ // Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
87
+ // back through the summaries it streams (auto.signals), so one router learns from every place.
88
+ const routed = await app.auto.resolve(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
89
+ const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
90
+ const sendBody = { ...routed, compare: routed.compare?.map(({ route, ...c }) => c) };
91
+ delete sendBody.route;
92
+ if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
93
+ const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
94
+ (out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
95
+ const known = store.projectFor(out.cwd, mid);
96
+ (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
97
+ store.saveConfig();
98
+ if (!known) app.broadcast('config', {});
99
+ return (out.group || [out]).map((x) => machines.summary(mid, x));
100
+ }
101
+
102
+ // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
103
+ async function replyTo(s, text, attachments = [], queue, mode, run) {
104
+ await ensureWorktree(s.worktree);
105
+ return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
106
+ }
107
+
108
+ function markDone(s) {
109
+ if (s.state === 'running' || s.state === 'waiting') return false;
110
+ s.state = 'done';
111
+ s.unread = false;
112
+ app.retro.schedule(s, 0);
113
+ app.sessionChanged(s);
114
+ return true;
115
+ }
116
+
117
+ async function runRecipe(r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) {
118
+ const values = words ? { ...positional(r.prompt, words), ...params } : params;
119
+ const [s] = await launchAny({ prompt: fill(r.prompt, values), cwd: r.cwd || cwd, machine, agent: agent || r.agent || undefined, model: model ?? (r.model || undefined), effort: effort || r.effort || undefined, mode: mode || r.mode || undefined }, { kind: 'recipe', id: r.id, name: r.name });
120
+ r.runs = (r.runs || 0) + 1;
121
+ r.lastRunAt = Date.now();
122
+ app.configChanged();
123
+ return s;
124
+ }
125
+
126
+ return { launch, launchAny, machineFor, pushUploads, replyTo, markDone, runRecipe };
127
+ }
@@ -0,0 +1,65 @@
1
+ // Phone (ntfy / Telegram): what a tap or a reply there does to a session, here or on a machine.
2
+
3
+ import { splitId } from '../machines.js';
4
+
5
+ export function phoneActions(app) {
6
+ const { store, runner, machines } = app;
7
+
8
+ // What the phone can do to a session, here or on a machine: { s, answer, reply, done } or null.
9
+ async function sessionOps(sessionId) {
10
+ const at = splitId(sessionId);
11
+ if (!at) {
12
+ const s = store.get(sessionId);
13
+ return s && { s, answer: (a) => runner.answer(s, a), reply: (t) => app.launcher.replyTo(s, t), done: () => app.launcher.markDone(s) };
14
+ }
15
+ const call = (method, p, body) => machines.json(at.machine, method, `/sessions/${at.id}${p}`, body);
16
+ const s = await call('GET', '').catch((err) => {
17
+ if (err.status === 404) return null;
18
+ throw err;
19
+ });
20
+ const live = s && (s.state === 'running' || s.state === 'waiting');
21
+ return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
22
+ }
23
+
24
+ return {
25
+ // /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
26
+ launch: async ({ project, prompt }) => {
27
+ const d = store.config.defaults;
28
+ const agent = d.agent || 'claude';
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' });
31
+ app.broadcast('config', {});
32
+ return s;
33
+ },
34
+ act: async ({ sessionId, kind, ref, index, text }) => {
35
+ const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
36
+ if (ops?.error) return `Can’t reach it: ${ops.error}`;
37
+ if (!ops) return 'That session is gone.';
38
+ const { s } = ops;
39
+ if (kind === 'answer') {
40
+ if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
41
+ const answer = s.ask.options[index];
42
+ if (answer === undefined) return 'Unknown option.';
43
+ await ops.answer(answer);
44
+ return `✓ Answered: ${answer}`;
45
+ }
46
+ if (kind === 'reply') {
47
+ const turn = s.turns.at(-1);
48
+ if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
49
+ const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
50
+ const o = q?.options?.[index];
51
+ const msg = typeof o === 'string' ? o : o?.reply || o?.label;
52
+ if (!msg) return 'Unknown option.';
53
+ await ops.reply(msg);
54
+ return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
55
+ }
56
+ if (kind === 'done') {
57
+ if (s.turns.at(-1)?.id !== ref && ref) return 'There is newer work on that session; open wisp to review it.';
58
+ return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
59
+ }
60
+ if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
61
+ const r = await ops.reply(String(text || '').trim());
62
+ return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
63
+ },
64
+ };
65
+ }