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.
@@ -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
- create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [] }) {
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: newId(),
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: newId(),
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.cwd,
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: systemPrompt({ askTool: adapter.askTool }) + this.promptExtra(session),
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: { ELECTRON_RUN_AS_NODE: '1', WISP_URL: this.bridge.url, WISP_TOKEN: this.bridge.token, WISP_SESSION: session.id },
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) turn.changes.push((entry = { file: ev.file, op: ev.op, edits: [] }));
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
- if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
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
- // The follow-up starts right away; don't notify for the run it replaced.
348
- if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
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 {