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.
- package/CHANGELOG.md +35 -1
- package/README.md +27 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/adapters/claude.js +50 -12
- package/server/client.js +2 -2
- package/server/index.js +173 -17
- package/server/machines.js +5 -0
- package/server/mcp-wisp.js +5 -5
- package/server/openapi.js +25 -3
- package/server/protocol.js +4 -0
- package/server/remote.js +103 -17
- package/server/router-priors.json +54 -0
- package/server/router.js +340 -0
- package/server/runner.js +66 -13
- package/server/store.js +10 -2
- package/server/subagents.js +6 -2
- package/server/telemetry.js +58 -7
- package/web/css/app.css +83 -7
- package/web/index.html +2 -0
- package/web/js/app.js +459 -54
package/server/machines.js
CHANGED
|
@@ -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
|
}
|
package/server/mcp-wisp.js
CHANGED
|
@@ -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: '
|
|
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)' },
|
package/server/protocol.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
+
}
|