wisp-agents 0.7.0 → 0.8.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 +38 -1
- package/README.md +76 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +409 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +147 -3
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +152 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +557 -16
- package/web/index.html +10 -3
- package/web/js/app.js +1727 -86
- package/web/js/blocks.js +396 -22
package/server/remote.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Phone: push "needs you" moments to ntfy or Telegram and take the answers back. Both run on
|
|
2
|
+
// outbound connections only (wisp polls them), so nothing has to reach this machine.
|
|
3
|
+
//
|
|
4
|
+
// ntfy notification with up to 3 buttons. A tap makes the ntfy app POST a signed answer to
|
|
5
|
+
// <topic>-reply, which wisp polls. No free text (ntfy has no reply box).
|
|
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.
|
|
8
|
+
|
|
9
|
+
import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
const KIND = { a: 'answer', r: 'reply', d: 'done' };
|
|
12
|
+
const CODE = { answer: 'a', reply: 'r', done: 'd' };
|
|
13
|
+
const MAX_TG_IDS = 20;
|
|
14
|
+
const sleep = (ms, signal) => new Promise((r) => (signal?.aborted ? r() : (setTimeout(r, ms).unref?.(), signal?.addEventListener('abort', r, { once: true }))));
|
|
15
|
+
const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
|
|
16
|
+
export const mask = (t) => (t ? `••••${String(t).slice(-4)}` : '');
|
|
17
|
+
|
|
18
|
+
export class Remote {
|
|
19
|
+
// 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 });
|
|
22
|
+
this.status = { ntfy: null, telegram: null };
|
|
23
|
+
this.ac = null;
|
|
24
|
+
this.ntfyPollMs = 4000;
|
|
25
|
+
this.tgLongPoll = 25;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get cfg() {
|
|
29
|
+
return (this.store.config.remote ||= {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get secret() {
|
|
33
|
+
if (!this.cfg.secret) {
|
|
34
|
+
this.cfg.secret = randomBytes(16).toString('hex');
|
|
35
|
+
this.store.saveConfig();
|
|
36
|
+
}
|
|
37
|
+
return this.cfg.secret;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get on() {
|
|
41
|
+
const c = this.cfg;
|
|
42
|
+
return !!((c.ntfy?.enabled && c.ntfy.topic) || (c.telegram?.enabled && c.telegram.token));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start() {
|
|
46
|
+
this.stop();
|
|
47
|
+
const ac = (this.ac = new AbortController());
|
|
48
|
+
const c = this.cfg;
|
|
49
|
+
if (c.ntfy?.enabled && c.ntfy.topic) this.#ntfyLoop(ac.signal);
|
|
50
|
+
if (c.telegram?.enabled && c.telegram.token) {
|
|
51
|
+
if (!c.telegram.chatId && !c.telegram.linkCode) (c.telegram.linkCode = String(randomInt(100000, 999999))), this.store.saveConfig();
|
|
52
|
+
this.#tgLoop(ac.signal);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop() {
|
|
57
|
+
this.ac?.abort();
|
|
58
|
+
this.ac = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What the phone gets for a session that needs you, and the buttons under it.
|
|
62
|
+
message(s, summary) {
|
|
63
|
+
const where = `${summary?.project || ''}: ${s.title}`;
|
|
64
|
+
if (s.ask) {
|
|
65
|
+
return {
|
|
66
|
+
title: `❓ ${where}`,
|
|
67
|
+
text: [s.ask.question, s.ask.context && clip(s.ask.context, 600)].filter(Boolean).join('\n\n'),
|
|
68
|
+
actions: (s.ask.options || []).map((label, index) => ({ label, kind: 'answer', ref: s.ask.id, index })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const turn = s.turns.at(-1);
|
|
72
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
73
|
+
const hero = blocks.find((b) => b?.type === 'hero');
|
|
74
|
+
const q = blocks.findLast((b) => b?.type === 'question');
|
|
75
|
+
const icon = { done: '✅', partial: '🟡', blocked: '⛔' }[hero?.status] || (turn?.status === 'error' ? '⛔' : '📝');
|
|
76
|
+
const options = (q?.options || []).map((o) => (typeof o === 'string' ? { label: o } : o));
|
|
77
|
+
return {
|
|
78
|
+
title: `${icon} ${where}`,
|
|
79
|
+
text: [hero?.title && hero.title !== s.title ? hero.title : '', hero?.summary || summary?.preview || 'Ready for review.', q?.question].filter(Boolean).join('\n\n'),
|
|
80
|
+
actions: options.length ? options.map((o, index) => ({ label: o.label || String(o.reply || ''), kind: 'reply', ref: turn.id, index })) : [{ label: 'Mark done', kind: 'done', ref: turn?.id || '', index: 0 }],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async notify(s, summary) {
|
|
85
|
+
if (!this.on || !s || s.agent === 'wisp') return;
|
|
86
|
+
const event = s.state === 'waiting' ? 'waiting' : 'finished';
|
|
87
|
+
if (!(this.cfg.events || ['waiting', 'finished']).includes(event)) return;
|
|
88
|
+
await this.send(this.message(s, summary), s);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async send(msg, s = null) {
|
|
92
|
+
const c = this.cfg;
|
|
93
|
+
const jobs = [];
|
|
94
|
+
if (c.ntfy?.enabled && c.ntfy.topic) jobs.push(this.#ntfyPublish(msg, s).then(() => this.#mark('ntfy', { ok: true }), (e) => this.#mark('ntfy', { ok: false, error: e.message })));
|
|
95
|
+
if (c.telegram?.enabled && c.telegram.token && c.telegram.chatId) jobs.push(this.#tgPublish(msg, s).then(() => this.#mark('telegram', { ok: true }), (e) => this.#mark('telegram', { ok: false, error: e.message })));
|
|
96
|
+
await Promise.all(jobs);
|
|
97
|
+
return jobs.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#mark(channel, st) {
|
|
101
|
+
this.status[channel] = { ...this.status[channel], ...st, at: Date.now() };
|
|
102
|
+
this.onChange();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- answers: signed, so a stranger who guesses the topic can't steer your agents ------------
|
|
106
|
+
|
|
107
|
+
sign(sessionId, kind, ref, index) {
|
|
108
|
+
return createHmac('sha256', this.secret).update(`${sessionId}.${kind}.${ref}.${index}`).digest('base64url').slice(0, 22);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
verify(p) {
|
|
112
|
+
if (!p || typeof p !== 'object' || !KIND[p.k] || typeof p.sig !== 'string') return false;
|
|
113
|
+
const want = Buffer.from(this.sign(p.s, KIND[p.k], p.r, p.i));
|
|
114
|
+
const got = Buffer.from(p.sig);
|
|
115
|
+
return want.length === got.length && timingSafeEqual(want, got);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- ntfy ------------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
#ntfy() {
|
|
121
|
+
const n = this.cfg.ntfy;
|
|
122
|
+
const server = String(n.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
|
123
|
+
return { server, topic: n.topic, headers: n.token ? { authorization: `Bearer ${n.token}` } : {} };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #ntfyPublish(msg, s) {
|
|
127
|
+
const { server, topic, headers } = this.#ntfy();
|
|
128
|
+
const actions = (s ? msg.actions : []).slice(0, 3).map((a) => {
|
|
129
|
+
const k = CODE[a.kind];
|
|
130
|
+
return { action: 'http', label: clip(a.label, 40), url: `${server}/${topic}-reply`, method: 'POST', headers, body: JSON.stringify({ s: s.id, k, r: a.ref, i: a.index, sig: this.sign(s.id, a.kind, a.ref, a.index) }), clear: true };
|
|
131
|
+
});
|
|
132
|
+
const r = await fetch(server, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
135
|
+
body: JSON.stringify({ topic, title: clip(msg.title, 200), message: clip(msg.text, 3000), tags: ['wisp'], ...(actions.length && { actions }) }),
|
|
136
|
+
signal: AbortSignal.timeout(10_000),
|
|
137
|
+
});
|
|
138
|
+
if (!r.ok) throw new Error(`ntfy: ${r.status} ${(await r.text().catch(() => '')).slice(0, 120)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async #ntfyLoop(signal) {
|
|
142
|
+
let since = String(Math.floor(Date.now() / 1000));
|
|
143
|
+
while (!signal.aborted) {
|
|
144
|
+
try {
|
|
145
|
+
const { server, topic, headers } = this.#ntfy();
|
|
146
|
+
const r = await fetch(`${server}/${topic}-reply/json?poll=1&since=${since}`, { headers, signal });
|
|
147
|
+
if (!r.ok) throw new Error(`ntfy: ${r.status}`);
|
|
148
|
+
for (const line of (await r.text()).split('\n')) {
|
|
149
|
+
if (!line.trim()) continue;
|
|
150
|
+
const m = JSON.parse(line);
|
|
151
|
+
if (m.id) since = m.id;
|
|
152
|
+
if (m.event !== 'message') continue;
|
|
153
|
+
let p = null;
|
|
154
|
+
try {
|
|
155
|
+
p = JSON.parse(m.message);
|
|
156
|
+
} catch {}
|
|
157
|
+
if (!this.verify(p)) continue; // not ours, or forged
|
|
158
|
+
await this.act({ sessionId: p.s, kind: KIND[p.k], ref: p.r, index: Number(p.i) }).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
if (this.status.ntfy?.error) this.#mark('ntfy', { ok: true, error: '' });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (signal.aborted) return;
|
|
163
|
+
this.#mark('ntfy', { ok: false, error: err.message });
|
|
164
|
+
}
|
|
165
|
+
await sleep(this.ntfyPollMs, signal);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Telegram --------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async #tg(method, body, signal) {
|
|
172
|
+
const base = (process.env.WISP_TELEGRAM_API || 'https://api.telegram.org').replace(/\/+$/, '');
|
|
173
|
+
const r = await fetch(`${base}/bot${this.cfg.telegram.token}/${method}`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
signal: signal || AbortSignal.timeout(15_000),
|
|
178
|
+
});
|
|
179
|
+
const j = await r.json().catch(() => ({}));
|
|
180
|
+
if (!j.ok) throw new Error(`Telegram: ${j.description || r.status}`);
|
|
181
|
+
return j.result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #tgPublish(msg, s) {
|
|
185
|
+
const buttons = (s ? msg.actions : []).map((a) => [{ text: clip(a.label, 60), callback_data: `${CODE[a.kind]}:${s.id}:${a.ref}:${a.index}` }]);
|
|
186
|
+
const sent = await this.#tg('sendMessage', {
|
|
187
|
+
chat_id: this.cfg.telegram.chatId,
|
|
188
|
+
text: clip(`${msg.title}\n\n${msg.text}${s ? '\n\n↩ Reply to this message to answer or follow up.' : ''}`, 4000),
|
|
189
|
+
...(buttons.length && { reply_markup: { inline_keyboard: buttons } }),
|
|
190
|
+
});
|
|
191
|
+
if (s && sent?.message_id) {
|
|
192
|
+
s.remote = { ...s.remote, tg: [...(s.remote?.tg || []), sent.message_id].slice(-MAX_TG_IDS) };
|
|
193
|
+
this.store.save(s);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #tgLoop(signal) {
|
|
198
|
+
let offset = 0;
|
|
199
|
+
while (!signal.aborted) {
|
|
200
|
+
try {
|
|
201
|
+
const updates = await this.#tg('getUpdates', { offset, timeout: this.tgLongPoll, allowed_updates: ['message', 'callback_query'] }, signal);
|
|
202
|
+
if (this.status.telegram?.error) this.#mark('telegram', { ok: true, error: '' });
|
|
203
|
+
for (const u of updates) {
|
|
204
|
+
offset = u.update_id + 1;
|
|
205
|
+
await this.#fromTelegram(u).catch((err) => console.warn(`wisp: telegram: ${err.message}`));
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (signal.aborted) return;
|
|
209
|
+
this.#mark('telegram', { ok: false, error: err.message });
|
|
210
|
+
await sleep(5000, signal);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async #fromTelegram(u) {
|
|
216
|
+
const t = this.cfg.telegram;
|
|
217
|
+
const say = (text, extra = {}) => this.#tg('sendMessage', { chat_id: t.chatId, text, ...extra });
|
|
218
|
+
if (u.callback_query) {
|
|
219
|
+
const q = u.callback_query;
|
|
220
|
+
if (!t.chatId || String(q.message?.chat?.id) !== String(t.chatId)) return;
|
|
221
|
+
const [k, sessionId, ref, i] = String(q.data || '').split(':');
|
|
222
|
+
const result = KIND[k] ? await this.act({ sessionId, kind: KIND[k], ref, index: Number(i) }) : 'Unknown button.';
|
|
223
|
+
await this.#tg('answerCallbackQuery', { callback_query_id: q.id, text: clip(result, 190) }).catch(() => {});
|
|
224
|
+
await this.#tg('editMessageReplyMarkup', { chat_id: t.chatId, message_id: q.message.message_id, reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const m = u.message;
|
|
228
|
+
if (!m?.text) return;
|
|
229
|
+
if (!t.chatId) {
|
|
230
|
+
// Linking: only the chat that sends the code shown in wisp's settings gets attached.
|
|
231
|
+
if (t.linkCode && m.text.replace(/^\/(start|link)\s*/, '').trim() === t.linkCode) {
|
|
232
|
+
Object.assign(t, { chatId: m.chat.id, chatName: m.chat.title || [m.chat.first_name, m.chat.last_name].filter(Boolean).join(' ') || m.chat.username || '', linkCode: '' });
|
|
233
|
+
this.store.saveConfig();
|
|
234
|
+
this.#mark('telegram', { ok: true, error: '' });
|
|
235
|
+
await say('✓ Linked to wisp. Questions and finished tasks will show up here.');
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (String(m.chat.id) !== String(t.chatId)) return;
|
|
240
|
+
const replyTo = m.reply_to_message?.message_id;
|
|
241
|
+
const s = replyTo && this.store.all().find((x) => x.remote?.tg?.includes(replyTo));
|
|
242
|
+
if (!s) return say('Reply to one of wisp’s messages to answer it or send a follow-up.');
|
|
243
|
+
await say(await this.act({ sessionId: s.id, kind: 'text', text: m.text }), { reply_to_message_id: m.message_id });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// For the settings view: config with secrets masked, plus channel health.
|
|
247
|
+
view() {
|
|
248
|
+
const c = this.cfg;
|
|
249
|
+
return {
|
|
250
|
+
events: c.events || ['waiting', 'finished'],
|
|
251
|
+
ntfy: { enabled: !!c.ntfy?.enabled, server: c.ntfy?.server || 'https://ntfy.sh', topic: c.ntfy?.topic || '', token: mask(c.ntfy?.token) },
|
|
252
|
+
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 || '' },
|
|
253
|
+
status: this.status,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// PUT body: { events, ntfy: {…}, telegram: {…, unlink} }. Masked tokens mean "unchanged".
|
|
258
|
+
update(body = {}) {
|
|
259
|
+
const c = this.cfg;
|
|
260
|
+
const keep = (v, prev) => (typeof v === 'string' && v.startsWith('••••') ? prev : v);
|
|
261
|
+
if (Array.isArray(body.events)) c.events = body.events.filter((e) => e === 'waiting' || e === 'finished');
|
|
262
|
+
if (body.ntfy) {
|
|
263
|
+
const n = { ...c.ntfy, ...body.ntfy };
|
|
264
|
+
n.token = keep(body.ntfy.token, c.ntfy?.token) || '';
|
|
265
|
+
if (n.server && !/^https?:\/\/\S+$/.test(n.server)) throw Object.assign(new Error('ntfy server must be an http(s) URL'), { status: 400 });
|
|
266
|
+
if (n.topic && !/^[\w-]{1,64}$/.test(n.topic)) throw Object.assign(new Error('ntfy topic: letters, digits, - and _ only'), { status: 400 });
|
|
267
|
+
c.ntfy = { enabled: !!n.enabled, server: n.server || 'https://ntfy.sh', topic: n.topic || '', token: n.token };
|
|
268
|
+
}
|
|
269
|
+
if (body.telegram) {
|
|
270
|
+
const prev = c.telegram || {};
|
|
271
|
+
const token = keep(body.telegram.token, prev.token) || '';
|
|
272
|
+
const relink = body.telegram.unlink || token !== prev.token;
|
|
273
|
+
c.telegram = { ...prev, enabled: !!(body.telegram.enabled ?? prev.enabled), token, ...(relink && { chatId: null, chatName: '', linkCode: token ? String(randomInt(100000, 999999)) : '' }) };
|
|
274
|
+
}
|
|
275
|
+
this.store.saveConfig();
|
|
276
|
+
this.start();
|
|
277
|
+
return this.view();
|
|
278
|
+
}
|
|
279
|
+
}
|
package/server/render.js
CHANGED
|
@@ -55,6 +55,18 @@ const R = {
|
|
|
55
55
|
`${b.branch ? `**Branch** \`${b.branch}\`\n\n` : ''}${list(b.items)
|
|
56
56
|
.map((c) => `- \`${str(c.hash).slice(0, 7)}\` ${str(c.message)}${c.additions !== undefined ? ` (+${c.additions}/−${c.deletions ?? 0})` : ''}`)
|
|
57
57
|
.join('\n')}`,
|
|
58
|
+
graph: (b) => {
|
|
59
|
+
const head = `**${str(b.title) || 'Branches'}**${b.ref ? ` ${b.ref}` : ''}${b.state ? ` · ${b.state}` : ''}${b.into ? ` → \`${b.into}\`${b.merge ? ` (${b.merge})` : ''}` : ''}`;
|
|
60
|
+
const commits = list(b.commits).map((c) => `- ${c.conflict ? '⚠️ ' : ''}\`${str(c.hash).slice(0, 7)}\` ${str(c.message)}${c.branch ? ` [${c.branch}]` : ''}${c.author ? ` — ${c.author}${c.when ? `, ${c.when}` : ''}` : ''}`);
|
|
61
|
+
const conflicts = list(b.conflicts).map((c, i) => {
|
|
62
|
+
const o = typeof c === 'string' ? { file: c } : c || {};
|
|
63
|
+
return `${i + 1}. \`${str(o.file)}${o.line || o.lines ? `:${o.line || o.lines}` : ''}\`${o.detail ? ` — ${o.detail}` : ''}${o.ours ? `\n - ours: \`${o.ours}\`` : ''}${o.theirs ? `\n - theirs: \`${o.theirs}\`` : ''}${o.resolution ? `\n - ✔︎ ${o.resolution}` : ''}`;
|
|
64
|
+
});
|
|
65
|
+
const checks = list(b.checks).map((c) => `${c.status === 'fail' ? '❌' : c.status === 'pending' ? '⏳' : '✅'} ${str(c.label || c)}${c.value ? ` ${c.value}` : ''}`);
|
|
66
|
+
return [head, commits.join('\n'), conflicts.length ? `**Conflicts**\n${conflicts.join('\n')}` : '', checks.join(' · '), b.summary ? str(b.summary) : '']
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('\n\n');
|
|
69
|
+
},
|
|
58
70
|
terminal: (b) => fence(`$ ${str(b.command)}\n${str(b.output)}${b.exitCode !== undefined && b.exitCode !== 0 ? `\n[exit ${b.exitCode}]` : ''}`, 'console'),
|
|
59
71
|
compare: (b) => ['before', 'after'].map((k) => `**${str(b[k]?.label) || (k === 'before' ? 'Before' : 'After')}**\n${list(b[k]?.items).map((i) => `- ${str(i)}`).join('\n')}`).join('\n\n'),
|
|
60
72
|
flow: (b) => {
|
|
@@ -68,6 +80,10 @@ const R = {
|
|
|
68
80
|
callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
|
|
69
81
|
image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
|
|
70
82
|
question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
|
|
83
|
+
actions: (b) =>
|
|
84
|
+
`${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
|
|
85
|
+
.map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
|
|
86
|
+
.join('\n')}`,
|
|
71
87
|
next: (b) => `**Next**\n${list(b.items).map((i) => `- ${str(i)}`).join('\n')}`,
|
|
72
88
|
};
|
|
73
89
|
|
|
@@ -96,5 +112,7 @@ export function sessionToMarkdown(session) {
|
|
|
96
112
|
out.push(`**❓ The agent is waiting for your answer:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
|
|
97
113
|
if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
|
|
98
114
|
}
|
|
115
|
+
// Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
|
|
116
|
+
if (session.worktree && turn?.status !== 'running') out.push(`_Changes are on branch \`${session.worktree.branch}\` in the worktree \`${session.worktree.path}\`${session.pr ? ` · PR #${session.pr.number} (${String(session.pr.state).toLowerCase()}): ${session.pr.url}` : ''}._`);
|
|
99
117
|
return out.join('\n\n');
|
|
100
118
|
}
|
package/server/runner.js
CHANGED
|
@@ -9,13 +9,27 @@ import path from 'node:path';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { adapters } from './adapters/index.js';
|
|
11
11
|
import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
|
|
12
|
+
import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
|
|
13
|
+
import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
12
14
|
|
|
13
15
|
const MAX_ACTIVITY = 400;
|
|
14
16
|
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
15
17
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
18
|
+
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
19
|
+
const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
|
|
16
20
|
|
|
17
21
|
export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
|
|
18
22
|
|
|
23
|
+
// Where the agent actually works: its own worktree when it has one, else the project folder.
|
|
24
|
+
export const workdir = (s) => s.worktree?.cwd || s.cwd;
|
|
25
|
+
|
|
26
|
+
// Worktree sessions get told where they are, so they don't go looking for the main checkout.
|
|
27
|
+
function workspaceNote(s) {
|
|
28
|
+
const wt = s.worktree;
|
|
29
|
+
if (!wt) return '';
|
|
30
|
+
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).`;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
function autoTitle(prompt) {
|
|
20
34
|
const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
21
35
|
return line.length > 64 ? `${line.slice(0, 63)}…` : line;
|
|
@@ -26,6 +40,9 @@ export function summarize(s, store) {
|
|
|
26
40
|
const last = s.turns.at(-1);
|
|
27
41
|
const hero = heroOf(last?.presentation);
|
|
28
42
|
const lastAct = last?.activity?.findLast?.((a) => a.kind !== 'text');
|
|
43
|
+
// A lead blocked on its subagents says who it's waiting for.
|
|
44
|
+
const busy = last?.status === 'running' ? (last.delegations || []).filter((d) => store?.get(d.sessionId)?.turns.find((t) => t.id === d.turnId)?.status === 'running') : [];
|
|
45
|
+
const waitingOn = busy.length ? `Waiting on ${[...new Set(busy.map((d) => d.name))].join(', ')}` : '';
|
|
29
46
|
return {
|
|
30
47
|
id: s.id,
|
|
31
48
|
title: s.title,
|
|
@@ -46,9 +63,21 @@ export function summarize(s, store) {
|
|
|
46
63
|
queued: s.queue?.length || 0,
|
|
47
64
|
status: last?.status === 'running' ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
|
|
48
65
|
preview: hero?.summary || '',
|
|
49
|
-
live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? lastAct?.label || 'Starting up' : '',
|
|
66
|
+
live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
|
|
50
67
|
asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
|
|
51
68
|
question: s.ask?.question || '',
|
|
69
|
+
branch: s.worktree?.branch || '',
|
|
70
|
+
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,
|
|
71
|
+
groupId: s.groupId || null,
|
|
72
|
+
origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
|
|
73
|
+
// The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
|
|
74
|
+
team: (s.subagents || []).flatMap((m) =>
|
|
75
|
+
copies(m).map((name) => {
|
|
76
|
+
const child = store?.get(s.children?.[name]);
|
|
77
|
+
return { name, agent: m.agent, model: m.model, effort: m.effort, used: !!child, busy: child?.state === 'running' };
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
...(s.parentId && { parentId: s.parentId, member: s.member }),
|
|
52
81
|
};
|
|
53
82
|
}
|
|
54
83
|
|
|
@@ -76,11 +105,12 @@ export class Runner extends EventEmitter {
|
|
|
76
105
|
}
|
|
77
106
|
}
|
|
78
107
|
|
|
79
|
-
|
|
108
|
+
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
109
|
+
create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '' }) {
|
|
80
110
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
81
111
|
const now = Date.now();
|
|
82
112
|
const session = {
|
|
83
|
-
id
|
|
113
|
+
id,
|
|
84
114
|
title: autoTitle(prompt),
|
|
85
115
|
titleAuto: true,
|
|
86
116
|
agent,
|
|
@@ -95,9 +125,14 @@ export class Runner extends EventEmitter {
|
|
|
95
125
|
agentSessionId: null,
|
|
96
126
|
queue: [],
|
|
97
127
|
turns: [],
|
|
128
|
+
...(worktree && { worktree }),
|
|
129
|
+
...(groupId && { groupId }),
|
|
130
|
+
...(origin && { origin }),
|
|
131
|
+
...(subagents.length && { subagents, children: {} }),
|
|
132
|
+
...(parentId && { parentId, member }),
|
|
98
133
|
};
|
|
99
134
|
this.store.put(session);
|
|
100
|
-
this.store.touchProject(cwd);
|
|
135
|
+
if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
|
|
101
136
|
this.#startTurn(session, prompt, attachments);
|
|
102
137
|
return session;
|
|
103
138
|
}
|
|
@@ -184,9 +219,75 @@ export class Runner extends EventEmitter {
|
|
|
184
219
|
return open ? { pending: true } : { cancelled: true };
|
|
185
220
|
}
|
|
186
221
|
|
|
222
|
+
// delegate: the lead hands tasks to members of its team. Each member is one child session,
|
|
223
|
+
// created on first use and resumed after that. Returns one record (or { error }) per task.
|
|
224
|
+
delegate(lead, tasks) {
|
|
225
|
+
const turn = lead.turns.at(-1);
|
|
226
|
+
if (turn?.status !== 'running') throw Object.assign(new Error('The lead agent is not running.'), { status: 409 });
|
|
227
|
+
const team = lead.subagents || [];
|
|
228
|
+
if (!team.length) throw Object.assign(new Error('This session has no subagents. The user can add them when launching.'), { status: 400 });
|
|
229
|
+
const out = [];
|
|
230
|
+
const claimed = new Set(); // copies handed a task in this call
|
|
231
|
+
const busy = (name) => claimed.has(name) || this.live.has(lead.children?.[name]);
|
|
232
|
+
for (const task of Array.isArray(tasks) ? tasks.slice(0, 12) : []) {
|
|
233
|
+
const want = String(task?.subagent || '').trim();
|
|
234
|
+
const m = team.find((x) => x.name === want || copies(x).includes(want));
|
|
235
|
+
const prompt = String(task?.prompt || '').trim();
|
|
236
|
+
if (!m) {
|
|
237
|
+
out.push({ subagent: want, error: `No subagent named "${want}". The team: ${team.map((x) => x.name).join(', ')}.` });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!prompt) {
|
|
241
|
+
out.push({ subagent: want, error: 'The task prompt is empty.' });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// A member name goes to its first free copy; a copy name (codex#2) to that copy.
|
|
245
|
+
const slot = (want === m.name ? copies(m) : [want]).find((n) => !busy(n));
|
|
246
|
+
if (!slot) {
|
|
247
|
+
out.push({ subagent: want, error: `${want} is still working${m.count > 1 && want === m.name ? ` (all ${m.count} copies)` : ''} on earlier tasks. Wait with subagent_results, then delegate again.` });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
claimed.add(slot);
|
|
251
|
+
let child = this.store.get(lead.children?.[slot]);
|
|
252
|
+
if (child) {
|
|
253
|
+
child.cwd = workdir(lead); // follows the lead if its worktree moved
|
|
254
|
+
this.#startTurn(child, prompt);
|
|
255
|
+
} else {
|
|
256
|
+
child = this.create({ prompt, cwd: workdir(lead), agent: m.agent, mode: lead.mode, model: m.model, effort: m.effort, parentId: lead.id, member: slot });
|
|
257
|
+
(lead.children ||= {})[slot] = child.id;
|
|
258
|
+
}
|
|
259
|
+
const d = { id: newId(), name: slot, member: m.name, agent: m.agent, model: m.model, effort: m.effort, sessionId: child.id, turnId: child.turns.at(-1).id, prompt, startedAt: Date.now() };
|
|
260
|
+
(turn.delegations ||= []).push(d);
|
|
261
|
+
out.push(d);
|
|
262
|
+
}
|
|
263
|
+
const sent = out.filter((d) => !d.error);
|
|
264
|
+
if (sent.length) this.#onEvent(lead, turn, { type: 'tool', kind: 'agent', label: `Delegated to ${[...new Set(sent.map((d) => d.name))].join(', ')}` });
|
|
265
|
+
this.#touch(lead, { turn: turn.id });
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Delegations by id (any turn), or the current turn's when ids is empty, with their child sessions.
|
|
270
|
+
delegations(lead, ids = []) {
|
|
271
|
+
const all = lead.turns.flatMap((t) => t.delegations || []);
|
|
272
|
+
const list = ids.length ? all.filter((d) => ids.includes(d.id)) : lead.turns.at(-1)?.delegations || [];
|
|
273
|
+
return list.map((d) => ({ d, child: this.store.get(d.sessionId) }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A child's state changed: the lead's card shows it, so the lead's turn re-renders too.
|
|
277
|
+
#touchLead(child) {
|
|
278
|
+
const lead = child.parentId && this.store.get(child.parentId);
|
|
279
|
+
if (!lead) return;
|
|
280
|
+
const turn = lead.turns.findLast((t) => t.delegations?.some((d) => d.sessionId === child.id)) || lead.turns.at(-1);
|
|
281
|
+
this.#touch(lead, { turn: turn?.id });
|
|
282
|
+
}
|
|
283
|
+
|
|
187
284
|
stop(session) {
|
|
188
285
|
session.queue = [];
|
|
189
286
|
delete session.ask;
|
|
287
|
+
for (const cid of Object.values(session.children || {})) {
|
|
288
|
+
const child = this.store.get(cid);
|
|
289
|
+
if (child) this.stop(child);
|
|
290
|
+
}
|
|
190
291
|
if (!this.live.has(session.id)) return;
|
|
191
292
|
this.stopping.add(session.id);
|
|
192
293
|
this.live.get(session.id).stop();
|
|
@@ -201,8 +302,16 @@ export class Runner extends EventEmitter {
|
|
|
201
302
|
}
|
|
202
303
|
|
|
203
304
|
#startTurn(session, prompt, attachments = []) {
|
|
305
|
+
const id = newId();
|
|
306
|
+
// A subagent's edits land in the lead's turn, whose checkpoint already covers them.
|
|
307
|
+
const checkpoint =
|
|
308
|
+
session.agent === 'wisp' || session.parentId
|
|
309
|
+
? null
|
|
310
|
+
: !session.turns.length && session.worktree?.head
|
|
311
|
+
? freshCheckpoint(session.worktree)
|
|
312
|
+
: captureCheckpoint(workdir(session), session.id, id);
|
|
204
313
|
const turn = {
|
|
205
|
-
id
|
|
314
|
+
id,
|
|
206
315
|
prompt,
|
|
207
316
|
agent: session.agent,
|
|
208
317
|
model: session.model,
|
|
@@ -211,6 +320,7 @@ export class Runner extends EventEmitter {
|
|
|
211
320
|
startedAt: Date.now(),
|
|
212
321
|
endedAt: null,
|
|
213
322
|
status: 'running',
|
|
323
|
+
checkpoint,
|
|
214
324
|
activity: [],
|
|
215
325
|
changes: [],
|
|
216
326
|
presentation: null,
|
|
@@ -221,28 +331,40 @@ export class Runner extends EventEmitter {
|
|
|
221
331
|
session.unread = false;
|
|
222
332
|
delete session.archived;
|
|
223
333
|
this.#touch(session, { turn: turn.id });
|
|
334
|
+
this.#touchLead(session);
|
|
224
335
|
this.emit('started', { session, turn });
|
|
225
336
|
|
|
226
337
|
let released = false;
|
|
227
338
|
let linger;
|
|
228
339
|
const adapter = adapters[session.agent];
|
|
340
|
+
// Leads get the delegate tool next to ask_user; subagents get neither (nobody to ask).
|
|
341
|
+
const team = adapter.askTool !== null ? session.subagents || [] : [];
|
|
342
|
+
const prompt0 = session.parentId
|
|
343
|
+
? systemPrompt({ ask: SUBAGENT_ASK })
|
|
344
|
+
: systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
|
|
229
345
|
const handle = adapter.run({
|
|
230
346
|
prompt,
|
|
231
347
|
attachments,
|
|
232
|
-
cwd: session
|
|
348
|
+
cwd: workdir(session),
|
|
233
349
|
resumeId: session.agentSessionId,
|
|
234
350
|
mode: session.mode,
|
|
235
351
|
model: session.model,
|
|
236
352
|
effort: session.effort || '',
|
|
237
|
-
systemPrompt:
|
|
238
|
-
mcpServers: this.bridge && adapter.askTool !== null
|
|
353
|
+
systemPrompt: prompt0 + workspaceNote(session) + this.promptExtra(session),
|
|
354
|
+
mcpServers: this.bridge && adapter.askTool !== null && !session.parentId
|
|
239
355
|
? {
|
|
240
356
|
wisp: {
|
|
241
357
|
command: process.execPath,
|
|
242
358
|
args: [ASK_SERVER],
|
|
243
359
|
// Inside the desktop app execPath is the Electron binary; without
|
|
244
360
|
// ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
|
|
245
|
-
env: {
|
|
361
|
+
env: {
|
|
362
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
363
|
+
WISP_URL: this.bridge.url,
|
|
364
|
+
WISP_TOKEN: this.bridge.token,
|
|
365
|
+
WISP_SESSION: session.id,
|
|
366
|
+
...(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) }))) }),
|
|
367
|
+
},
|
|
246
368
|
},
|
|
247
369
|
}
|
|
248
370
|
: {},
|
|
@@ -294,10 +416,24 @@ export class Runner extends EventEmitter {
|
|
|
294
416
|
}
|
|
295
417
|
case 'change': {
|
|
296
418
|
let entry = turn.changes.find((c) => c.file === ev.file);
|
|
297
|
-
if (!entry)
|
|
419
|
+
if (!entry) {
|
|
420
|
+
let beforeFile = ev.beforeFile;
|
|
421
|
+
if (turn.checkpoint?.commit && !path.isAbsolute(ev.file)) {
|
|
422
|
+
const fromGit = readGitFile(workdir(session), turn.checkpoint.commit, ev.file);
|
|
423
|
+
if (fromGit !== null) beforeFile = fromGit;
|
|
424
|
+
else if (!(turn.checkpoint.untracked || []).includes(ev.file)) beforeFile = null;
|
|
425
|
+
}
|
|
426
|
+
turn.changes.push((entry = { file: ev.file, op: ev.op, ...(beforeFile !== undefined ? { beforeFile } : {}), edits: [] }));
|
|
427
|
+
}
|
|
298
428
|
if (ev.op === 'create' && entry.edits.length === 0) entry.op = 'create';
|
|
429
|
+
delete entry.reverted;
|
|
299
430
|
entry.edits.push(...ev.edits);
|
|
431
|
+
if (ev.by && !entry.by?.includes(ev.by)) (entry.by ||= []).push(ev.by);
|
|
300
432
|
this.store.save(session);
|
|
433
|
+
// A subagent's edits are the lead's too: they show up in its files, diffs and Ship it.
|
|
434
|
+
const lead = session.parentId && this.store.get(session.parentId);
|
|
435
|
+
const leadTurn = lead?.turns.at(-1);
|
|
436
|
+
if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
|
|
301
437
|
return;
|
|
302
438
|
}
|
|
303
439
|
case 'result':
|
|
@@ -337,15 +473,18 @@ export class Runner extends EventEmitter {
|
|
|
337
473
|
turn.presentation = parsePresentation(ev.text);
|
|
338
474
|
this.store.keepImages(session, turn);
|
|
339
475
|
const hero = heroOf(turn.presentation);
|
|
340
|
-
|
|
476
|
+
// Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
|
|
477
|
+
if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
|
|
341
478
|
}
|
|
342
479
|
session.state = 'review';
|
|
343
480
|
session.unread = !interrupted;
|
|
344
481
|
this.store.save(session, { now: true });
|
|
345
482
|
this.#touch(session, { turn: turn.id });
|
|
346
483
|
this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
|
|
347
|
-
|
|
348
|
-
|
|
484
|
+
this.#touchLead(session);
|
|
485
|
+
// The follow-up starts right away; don't notify for the run it replaced. A subagent reports
|
|
486
|
+
// to its lead, not to the user.
|
|
487
|
+
if (!interrupted && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
|
|
349
488
|
}
|
|
350
489
|
|
|
351
490
|
#touch(session, { turn, quiet } = {}) {
|
package/server/store.js
CHANGED
|
@@ -9,6 +9,10 @@ import { parsePresentation } from './protocol.js';
|
|
|
9
9
|
const DEFAULT_CONFIG = {
|
|
10
10
|
projects: [], // [{ id, name, path, color, addedAt }]
|
|
11
11
|
defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
|
|
12
|
+
recipes: [], // saved prompts with {params}: server/recipes.js
|
|
13
|
+
triggers: [], // schedules and GitHub watchers: server/triggers.js
|
|
14
|
+
teams: [], // saved subagent presets: server/subagents.js
|
|
15
|
+
remote: {}, // phone channels (ntfy, Telegram): server/remote.js
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
@@ -47,6 +51,10 @@ export class Store {
|
|
|
47
51
|
const raw = JSON.parse(fs.readFileSync(path.join(this.home, 'config.json'), 'utf8'));
|
|
48
52
|
const cfg = { ...structuredClone(DEFAULT_CONFIG), ...raw, defaults: { ...DEFAULT_CONFIG.defaults, ...raw.defaults } };
|
|
49
53
|
cfg.projects ||= [];
|
|
54
|
+
cfg.recipes ||= [];
|
|
55
|
+
cfg.triggers ||= [];
|
|
56
|
+
cfg.teams ||= [];
|
|
57
|
+
cfg.remote ||= {};
|
|
50
58
|
return cfg;
|
|
51
59
|
} catch {
|
|
52
60
|
return structuredClone(DEFAULT_CONFIG);
|
|
@@ -130,7 +138,7 @@ export class Store {
|
|
|
130
138
|
(turn.presentation?.blocks || []).forEach((b, i) => {
|
|
131
139
|
const rel = b?.type === 'image' && (b.path || b.src);
|
|
132
140
|
if (typeof rel !== 'string' || !rel || /^https?:/.test(rel)) return;
|
|
133
|
-
const src = path.resolve(session.cwd || this.home, rel);
|
|
141
|
+
const src = path.resolve(session.worktree?.cwd || session.cwd || this.home, rel);
|
|
134
142
|
const ext = path.extname(src).toLowerCase();
|
|
135
143
|
if (!IMAGE_EXT.has(ext) || src.startsWith(this.shots + path.sep)) return;
|
|
136
144
|
try {
|