wisp-agents 0.12.0 → 0.14.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.
@@ -266,6 +266,11 @@ export class Machines extends EventEmitter {
266
266
  return m;
267
267
  }
268
268
 
269
+ // The harnesses the machine reported when it came online (Auto routes with these), or null.
270
+ agentsOf(id) {
271
+ return this.conns.get(id)?.remote?.agents || null;
272
+ }
273
+
269
274
  isOnline(id) {
270
275
  return this.conns.get(id)?.state === 'online';
271
276
  }
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import { WispClient } from './client.js';
10
10
 
11
11
  const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
- const AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'];
12
+ const AGENTS = ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'];
13
13
  const DEFAULT_WAIT_S = 240; // stay under typical MCP client tool-call timeouts; call wisp_wait again for more
14
14
 
15
15
  const TOOLS = [
@@ -24,7 +24,7 @@ const TOOLS = [
24
24
  properties: {
25
25
  prompt: { type: 'string', description: 'The task, written as you would brief a colleague: goal, constraints, how to verify.' },
26
26
  cwd: { type: 'string', description: 'Absolute path of the project folder. Default: this MCP server’s working directory.' },
27
- agent: { type: 'string', enum: AGENTS, description: 'Harness to run. Default: the user’s last pick in wisp.' },
27
+ agent: { type: 'string', enum: AGENTS, description: 'Harness to run; auto lets wisp pick harness and model from what worked before. Default: the user’s last pick in wisp.' },
28
28
  mode: { type: 'string', enum: ['plan', 'edits', 'auto'], description: 'plan = read-only, edits = auto-accept file edits, auto = no permission prompts. Default: the user’s pick.' },
29
29
  model: { type: 'string', description: 'Model id for that harness. Default: the user’s pick.' },
30
30
  effort: { type: 'string', description: 'Thinking level, e.g. low | medium | high.' },
@@ -66,8 +66,8 @@ const TOOLS = [
66
66
  name: 'wisp_reply',
67
67
  description:
68
68
  'Send a follow-up to a session; it resumes the same agent conversation. If the agent is waiting on a question, this answers it. ' +
69
- 'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead).',
70
- inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, queue: { type: 'boolean' } }, required: ['id', 'text'] },
69
+ 'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead). mode switches the session’s mode from this follow-up on.',
70
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, queue: { type: 'boolean' }, mode: { type: 'string', enum: ['plan', 'edits', 'auto'] } }, required: ['id', 'text'] },
71
71
  },
72
72
  {
73
73
  name: 'wisp_stop',
@@ -118,7 +118,7 @@ async function callTool(name, a = {}) {
118
118
  return waitFor(a.id);
119
119
  case 'wisp_reply': {
120
120
  if (!a.text?.trim()) throw new Error('text is required');
121
- const r = await c.reply(a.id, a.text.trim(), { queue: a.queue });
121
+ const r = await c.reply(a.id, a.text.trim(), { queue: a.queue, mode: a.mode });
122
122
  return r.answered ? 'Answered the agent’s question; it is continuing.' : r.interrupted ? 'Interrupted the run; it resumes with your message.' : r.queued ? 'Queued; it goes out when the current run finishes.' : 'Sent; the agent is working on it.';
123
123
  }
124
124
  case 'wisp_stop':
package/server/openapi.js CHANGED
@@ -45,7 +45,28 @@ export function openapi({ version, port }) {
45
45
  },
46
46
  },
47
47
  '/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
48
+ '/router': {
49
+ get: { summary: 'Auto: the goal, settings, every harness · model with its learned chance of success and cost, and recent routes', responses: { 200: json({ type: 'object' }) } },
50
+ patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
51
+ },
52
+ '/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
48
53
  '/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
54
+ '/feedback': {
55
+ post: {
56
+ summary: 'Send feedback to the wisp team: thumbs on a turn and/or a note. Sent even with usage stats off; WISP_FEEDBACK=0 turns it off',
57
+ requestBody: body({
58
+ type: 'object',
59
+ properties: {
60
+ rating: { type: ['string', 'null'], enum: ['up', 'down', null], description: 'With turnId: kept on that turn; null takes it back' },
61
+ text: { type: 'string', maxLength: 4000 },
62
+ sessionId: { type: 'string', description: 'Adds that run\'s harness, model and status (never the prompt or result)' },
63
+ turnId: { type: 'string' },
64
+ source: { type: 'string', description: 'turn | command | dialog' },
65
+ },
66
+ }),
67
+ responses: { 200: json({ type: 'object', properties: { ok: { type: 'boolean' }, sent: { type: 'boolean', description: 'false: kept in ~/.wisp/feedback-outbox.json and retried' }, available: { type: 'boolean' } } }), 400: err[400] },
68
+ },
69
+ },
49
70
  '/config': {
50
71
  get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
51
72
  patch: {
@@ -140,7 +161,7 @@ export function openapi({ version, port }) {
140
161
  get: { summary: 'Phone channels (ntfy, Telegram) with secrets masked, and their status', responses: { 200: json({ type: 'object' }) } },
141
162
  put: {
142
163
  summary: 'Configure phone channels. A masked token (••••1234) means unchanged',
143
- requestBody: body({ type: 'object', properties: { events: { type: 'array', items: { enum: ['waiting', 'finished'] } }, ntfy: { type: 'object', properties: { enabled: { type: 'boolean' }, server: { type: 'string' }, topic: { type: 'string' }, token: { type: 'string' } } }, telegram: { type: 'object', properties: { enabled: { type: 'boolean' }, token: { type: 'string' }, unlink: { type: 'boolean' } } } } }),
164
+ requestBody: body({ type: 'object', properties: { events: { type: 'array', items: { enum: ['waiting', 'finished'] } }, ntfy: { type: 'object', properties: { enabled: { type: 'boolean' }, server: { type: 'string' }, topic: { type: 'string' }, token: { type: 'string' } } }, telegram: { type: 'object', properties: { enabled: { type: 'boolean' }, token: { type: 'string' }, unlink: { type: 'boolean' }, allowStart: { type: 'boolean', description: '/new in the linked chat starts tasks' }, project: { type: 'string', description: 'Project id /new uses without @name; empty = last used' } } } } }),
144
165
  responses: { 200: json({ type: 'object' }), 400: err[400] },
145
166
  },
146
167
  },
@@ -184,7 +205,7 @@ export function openapi({ version, port }) {
184
205
  parameters: [id],
185
206
  post: {
186
207
  summary: 'Follow up (answers a pending ask_user question if there is one)',
187
- requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' } } }),
208
+ requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' } } }),
188
209
  responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
189
210
  },
190
211
  },
@@ -248,7 +269,8 @@ export function openapi({ version, port }) {
248
269
  properties: {
249
270
  prompt: { type: 'string' },
250
271
  cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
251
- agent: { enum: ['claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'claude' },
272
+ 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)' },
273
+ important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
252
274
  mode: { enum: ['plan', 'edits', 'auto'], default: 'edits' },
253
275
  model: { type: 'string' },
254
276
  effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
@@ -14,6 +14,10 @@ presentation you put in your final message. Work autonomously:
14
14
  - If the task is done but there is a choice for later, end with a "question" block instead.
15
15
  - Verify your work where it is cheap (tests, type-check, lint, build) and report results honestly,
16
16
  including failures. Never claim something passes that you did not run.
17
+ - Your final message ends the task. Don't end a turn with "I'll report once it has finished": wait for
18
+ background work (builds, test runs, background shells) to finish, then report its result. Anything
19
+ still running when you finish may be stopped; if you leave something up on purpose (a dev server),
20
+ say so.
17
21
  - The user can follow up; follow-ups resume this same session.
18
22
  - Attached files (images, video, docs) are listed in an <attachments> section of the prompt.
19
23
 
package/server/remote.js CHANGED
@@ -4,26 +4,37 @@
4
4
  // ntfy notification with up to 3 buttons. A tap makes the ntfy app POST a signed answer to
5
5
  // <topic>-reply, which wisp polls. No free text (ntfy has no reply box).
6
6
  // Telegram a bot message with buttons; reply to the message to answer in your own words or
7
- // send a follow-up. Linked to one chat with a one-time code.
7
+ // send a follow-up. Linked to one chat with a one-time code. With allowStart on,
8
+ // /new [@project] <task> starts a task and /projects lists where it can run.
8
9
 
9
10
  import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
10
11
 
11
12
  const KIND = { a: 'answer', r: 'reply', d: 'done' };
12
13
  const CODE = { answer: 'a', reply: 'r', done: 'd' };
13
14
  const MAX_TG_IDS = 20;
15
+ const STALE_S = 10 * 60;
14
16
  const sleep = (ms, signal) => new Promise((r) => (signal?.aborted ? r() : (setTimeout(r, ms).unref?.(), signal?.addEventListener('abort', r, { once: true }))));
15
17
  const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
18
+ // /new @name: exact name, then name prefix, then anywhere in the name or path.
19
+ export function findProject(projects, q) {
20
+ const n = String(q || '').toLowerCase();
21
+ if (!n) return null;
22
+ const name = (p) => String(p.name || '').toLowerCase();
23
+ return projects.find((p) => name(p) === n) || projects.find((p) => name(p).startsWith(n)) || projects.find((p) => name(p).includes(n) || String(p.path || '').toLowerCase().includes(n)) || null;
24
+ }
16
25
  export const mask = (t) => (t ? `••••${String(t).slice(-4)}` : '');
17
26
 
18
27
  export class Remote {
19
28
  // act({ sessionId, kind: answer|reply|done|text, ref, index, text }) → a short result line.
20
- constructor({ store, act, onChange = () => {} }) {
21
- Object.assign(this, { store, act, onChange });
29
+ // launch({ project, prompt }) → the new session's summary (a task started with /new).
30
+ constructor({ store, act, launch = async () => null, onChange = () => {} }) {
31
+ Object.assign(this, { store, act, launch, onChange });
22
32
  this.status = { ntfy: null, telegram: null };
23
33
  this.tgElsewhere = new Map(); // Telegram message id -> session id, for sessions on a machine (server/machines.js)
24
34
  this.ac = null;
25
35
  this.ntfyPollMs = 4000;
26
36
  this.tgLongPoll = 25;
37
+ this.tgOffset = 0;
27
38
  }
28
39
 
29
40
  get cfg() {
@@ -189,23 +200,19 @@ export class Remote {
189
200
  text: clip(`${msg.title}\n\n${msg.text}${s ? '\n\n↩ Reply to this message to answer or follow up.' : ''}`, 4000),
190
201
  ...(buttons.length && { reply_markup: { inline_keyboard: buttons } }),
191
202
  });
192
- if (s && sent?.message_id && this.store.get(s.id) === s) {
193
- s.remote = { ...s.remote, tg: [...(s.remote?.tg || []), sent.message_id].slice(-MAX_TG_IDS) };
194
- this.store.save(s);
195
- } else if (s && sent?.message_id) {
196
- this.tgElsewhere.set(sent.message_id, s.id);
197
- if (this.tgElsewhere.size > 200) this.tgElsewhere.delete(this.tgElsewhere.keys().next().value);
198
- }
203
+ if (s && sent?.message_id) this.#tgLink(s.id, sent.message_id);
199
204
  }
200
205
 
206
+ // The offset outlives restarts (every settings change restarts the loop), or Telegram would
207
+ // hand back updates already handled and a /new would start its task twice.
201
208
  async #tgLoop(signal) {
202
- let offset = 0;
203
209
  while (!signal.aborted) {
204
210
  try {
205
- const updates = await this.#tg('getUpdates', { offset, timeout: this.tgLongPoll, allowed_updates: ['message', 'callback_query'] }, signal);
211
+ const updates = await this.#tg('getUpdates', { offset: this.tgOffset, timeout: this.tgLongPoll, allowed_updates: ['message', 'callback_query'] }, signal);
206
212
  if (this.status.telegram?.error) this.#mark('telegram', { ok: true, error: '' });
207
213
  for (const u of updates) {
208
- offset = u.update_id + 1;
214
+ if (u.update_id < this.tgOffset) continue;
215
+ this.tgOffset = u.update_id + 1;
209
216
  await this.#fromTelegram(u).catch((err) => console.warn(`wisp: telegram: ${err.message}`));
210
217
  }
211
218
  } catch (err) {
@@ -241,19 +248,88 @@ export class Remote {
241
248
  return;
242
249
  }
243
250
  if (String(m.chat.id) !== String(t.chatId)) return;
251
+ const cmd = m.text.match(/^\/(\w+)(?:@\w+)?\s*([\s\S]*)$/);
252
+ if (cmd && ['new', 'projects', 'help', 'start'].includes(cmd[1].toLowerCase())) return this.#tgCommand(cmd[1].toLowerCase(), cmd[2].trim(), m, say);
244
253
  const replyTo = m.reply_to_message?.message_id;
245
254
  const sessionId = replyTo && (this.store.all().find((x) => x.remote?.tg?.includes(replyTo))?.id || this.tgElsewhere.get(replyTo));
246
- if (!sessionId) return say('Reply to one of wisp’s messages to answer it or send a follow-up.');
255
+ if (!sessionId) return say(t.allowStart ? 'Reply to one of wisp’s messages to answer it or follow up, or send /new <task> to start one.' : 'Reply to one of wisp’s messages to answer it or send a follow-up.');
247
256
  await say(await this.act({ sessionId, kind: 'text', text: m.text }), { reply_to_message_id: m.message_id });
248
257
  }
249
258
 
259
+ async #tgCommand(name, arg, m, say) {
260
+ const t = this.cfg.telegram;
261
+ const off = 'Starting tasks from Telegram is off. Turn it on in wisp → Phone → Telegram.';
262
+ const projects = this.store.config.projects;
263
+ if (name === 'help' || name === 'start')
264
+ return say(
265
+ t.allowStart
266
+ ? 'Send /new <task> to start a task in your default project, or /new @project <task> to pick one (/projects lists them). Reply to wisp’s messages to answer or follow up.'
267
+ : `Reply to wisp’s messages to answer or follow up. ${off}`,
268
+ );
269
+ if (!t.allowStart) return say(off);
270
+ // Telegram keeps updates for a day: don't start what was sent while wisp was off.
271
+ if (name === 'new' && m.date && Date.now() / 1000 - m.date > STALE_S) return say('That /new arrived while wisp was offline, so it didn’t start. Send it again to run it now.', { reply_to_message_id: m.message_id });
272
+ if (name === 'projects') {
273
+ if (!projects.length) return say('No projects yet. Open a folder in wisp first.');
274
+ const def = this.defaultProject();
275
+ const where = (id) => (this.store.config.machines || []).find((x) => x.id === id)?.name || id;
276
+ const lines = projects.map((p) => `• ${p.name}${p.machine ? ` (on ${where(p.machine)})` : ''}${p === def ? ' ← default' : ''}`);
277
+ return say(clip(`Projects:\n${lines.join('\n')}\n\n/new @name <task> runs in one of them.`, 4000));
278
+ }
279
+ let prompt = arg;
280
+ let project = this.defaultProject();
281
+ const at = arg.match(/^@(\S+)\s*([\s\S]*)$/);
282
+ if (at) {
283
+ project = findProject(projects, at[1]);
284
+ if (!project) return say(`No project matches “${clip(at[1], 60)}”. Send /projects to see them.`);
285
+ prompt = at[2].trim();
286
+ }
287
+ if (!prompt) return say('What should it do? Send /new <task>, or /new @project <task>.');
288
+ if (!project) return say('No projects yet. Open a folder in wisp first.');
289
+ let s;
290
+ try {
291
+ s = await this.launch({ project, prompt });
292
+ } catch (err) {
293
+ return say(`Couldn’t start it: ${clip(err.message, 300)}`, { reply_to_message_id: m.message_id });
294
+ }
295
+ const sent = await say(clip(`▶ Started in ${project.name}: ${s?.title || prompt}\n\nwisp will message you when it needs you or is done. Reply to this message to follow up.`, 4000), { reply_to_message_id: m.message_id });
296
+ if (s?.id && sent?.message_id) this.#tgLink(s.id, sent.message_id);
297
+ }
298
+
299
+ // The project /new uses without an @name: the one picked in settings, else the last used.
300
+ defaultProject() {
301
+ const projects = this.store.config.projects;
302
+ const id = this.cfg.telegram?.project;
303
+ return (id && projects.find((p) => p.id === id)) || [...projects].sort((a, b) => (b.lastUsedAt || b.addedAt || 0) - (a.lastUsedAt || a.addedAt || 0))[0] || null;
304
+ }
305
+
306
+ // Replies to a Telegram message reach the session it belongs to.
307
+ #tgLink(sessionId, messageId) {
308
+ const s = this.store.get(sessionId);
309
+ if (s) {
310
+ s.remote = { ...s.remote, tg: [...(s.remote?.tg || []), messageId].slice(-MAX_TG_IDS) };
311
+ this.store.save(s);
312
+ return;
313
+ }
314
+ this.tgElsewhere.set(messageId, sessionId);
315
+ if (this.tgElsewhere.size > 200) this.tgElsewhere.delete(this.tgElsewhere.keys().next().value);
316
+ }
317
+
250
318
  // For the settings view: config with secrets masked, plus channel health.
251
319
  view() {
252
320
  const c = this.cfg;
253
321
  return {
254
322
  events: c.events || ['waiting', 'finished'],
255
323
  ntfy: { enabled: !!c.ntfy?.enabled, server: c.ntfy?.server || 'https://ntfy.sh', topic: c.ntfy?.topic || '', token: mask(c.ntfy?.token) },
256
- telegram: { enabled: !!c.telegram?.enabled, token: mask(c.telegram?.token), linked: !!c.telegram?.chatId, chatName: c.telegram?.chatName || '', linkCode: c.telegram?.chatId ? '' : c.telegram?.linkCode || '' },
324
+ telegram: {
325
+ enabled: !!c.telegram?.enabled,
326
+ token: mask(c.telegram?.token),
327
+ linked: !!c.telegram?.chatId,
328
+ chatName: c.telegram?.chatName || '',
329
+ linkCode: c.telegram?.chatId ? '' : c.telegram?.linkCode || '',
330
+ allowStart: !!c.telegram?.allowStart,
331
+ project: c.telegram?.project || '',
332
+ },
257
333
  status: this.status,
258
334
  };
259
335
  }
@@ -272,9 +348,19 @@ export class Remote {
272
348
  }
273
349
  if (body.telegram) {
274
350
  const prev = c.telegram || {};
275
- const token = keep(body.telegram.token, prev.token) || '';
351
+ const token = (body.telegram.token === undefined ? prev.token : keep(body.telegram.token, prev.token)) || ''; // missing = unchanged
276
352
  const relink = body.telegram.unlink || token !== prev.token;
277
- c.telegram = { ...prev, enabled: !!(body.telegram.enabled ?? prev.enabled), token, ...(relink && { chatId: null, chatName: '', linkCode: token ? String(randomInt(100000, 999999)) : '' }) };
353
+ if (token !== prev.token) this.tgOffset = 0; // another bot, another update counter
354
+ const project = body.telegram.project ?? prev.project ?? '';
355
+ if (project && !this.store.config.projects.some((p) => p.id === project)) throw Object.assign(new Error('Unknown project'), { status: 400 });
356
+ c.telegram = {
357
+ ...prev,
358
+ enabled: !!(body.telegram.enabled ?? prev.enabled),
359
+ token,
360
+ allowStart: !!(body.telegram.allowStart ?? prev.allowStart),
361
+ project,
362
+ ...(relink && { chatId: null, chatName: '', linkCode: token ? String(randomInt(100000, 999999)) : '' }),
363
+ };
278
364
  }
279
365
  this.store.saveConfig();
280
366
  this.start();
@@ -0,0 +1,54 @@
1
+ {
2
+ "version": "pilot-2026-10-18",
3
+ "source": "wisp-bench pilot: 121 runs on SWE-bench Verified, Harness-Bench and replayed wisp PRs, graded by hidden tests (first attempt). Thin: treat as a starting point.",
4
+ "arms": {
5
+ "opencode:opencode/deepseek-v4.1-flash-free": {
6
+ "a": 10,
7
+ "b": 0,
8
+ "usd": 0.0,
9
+ "min": 3.1
10
+ },
11
+ "claude:haiku": {
12
+ "a": 18,
13
+ "b": 3,
14
+ "usd": 0.017,
15
+ "min": 1.5
16
+ },
17
+ "claude:sonnet": {
18
+ "a": 18,
19
+ "b": 3,
20
+ "usd": 0.137,
21
+ "min": 0.7
22
+ },
23
+ "grok:grok-4.7": {
24
+ "a": 10,
25
+ "b": 2,
26
+ "usd": 0.984,
27
+ "min": 5.2
28
+ },
29
+ "claude:opus": {
30
+ "a": 17,
31
+ "b": 4,
32
+ "usd": 0.344,
33
+ "min": 1.3
34
+ },
35
+ "antigravity:gemini-3.8-flash-medium": {
36
+ "a": 4,
37
+ "b": 1,
38
+ "usd": 0.054,
39
+ "min": 4.4
40
+ },
41
+ "codex:": {
42
+ "a": 19,
43
+ "b": 5,
44
+ "usd": 0.129,
45
+ "min": 0.6
46
+ },
47
+ "antigravity:gemini-4-argon-medium": {
48
+ "a": 5,
49
+ "b": 2,
50
+ "usd": 0.677,
51
+ "min": 4.7
52
+ }
53
+ }
54
+ }