wisp-agents 0.6.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/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
 
@@ -62,6 +91,7 @@ export class Runner extends EventEmitter {
62
91
  this.stopping = new Set();
63
92
  this.interrupting = new Set();
64
93
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
94
+ this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
65
95
 
66
96
  // Anything that was mid-flight when wisp last died is now orphaned.
67
97
  for (const s of store.all()) {
@@ -75,11 +105,12 @@ export class Runner extends EventEmitter {
75
105
  }
76
106
  }
77
107
 
78
- 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 = '' }) {
79
110
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
80
111
  const now = Date.now();
81
112
  const session = {
82
- id: newId(),
113
+ id,
83
114
  title: autoTitle(prompt),
84
115
  titleAuto: true,
85
116
  agent,
@@ -94,9 +125,14 @@ export class Runner extends EventEmitter {
94
125
  agentSessionId: null,
95
126
  queue: [],
96
127
  turns: [],
128
+ ...(worktree && { worktree }),
129
+ ...(groupId && { groupId }),
130
+ ...(origin && { origin }),
131
+ ...(subagents.length && { subagents, children: {} }),
132
+ ...(parentId && { parentId, member }),
97
133
  };
98
134
  this.store.put(session);
99
- this.store.touchProject(cwd);
135
+ if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
100
136
  this.#startTurn(session, prompt, attachments);
101
137
  return session;
102
138
  }
@@ -183,9 +219,75 @@ export class Runner extends EventEmitter {
183
219
  return open ? { pending: true } : { cancelled: true };
184
220
  }
185
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
+
186
284
  stop(session) {
187
285
  session.queue = [];
188
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
+ }
189
291
  if (!this.live.has(session.id)) return;
190
292
  this.stopping.add(session.id);
191
293
  this.live.get(session.id).stop();
@@ -200,8 +302,16 @@ export class Runner extends EventEmitter {
200
302
  }
201
303
 
202
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);
203
313
  const turn = {
204
- id: newId(),
314
+ id,
205
315
  prompt,
206
316
  agent: session.agent,
207
317
  model: session.model,
@@ -210,6 +320,7 @@ export class Runner extends EventEmitter {
210
320
  startedAt: Date.now(),
211
321
  endedAt: null,
212
322
  status: 'running',
323
+ checkpoint,
213
324
  activity: [],
214
325
  changes: [],
215
326
  presentation: null,
@@ -220,28 +331,40 @@ export class Runner extends EventEmitter {
220
331
  session.unread = false;
221
332
  delete session.archived;
222
333
  this.#touch(session, { turn: turn.id });
334
+ this.#touchLead(session);
223
335
  this.emit('started', { session, turn });
224
336
 
225
337
  let released = false;
226
338
  let linger;
227
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'));
228
345
  const handle = adapter.run({
229
346
  prompt,
230
347
  attachments,
231
- cwd: session.cwd,
348
+ cwd: workdir(session),
232
349
  resumeId: session.agentSessionId,
233
350
  mode: session.mode,
234
351
  model: session.model,
235
352
  effort: session.effort || '',
236
- systemPrompt: systemPrompt({ askTool: adapter.askTool }),
237
- mcpServers: this.bridge && adapter.askTool !== null
353
+ systemPrompt: prompt0 + workspaceNote(session) + this.promptExtra(session),
354
+ mcpServers: this.bridge && adapter.askTool !== null && !session.parentId
238
355
  ? {
239
356
  wisp: {
240
357
  command: process.execPath,
241
358
  args: [ASK_SERVER],
242
359
  // Inside the desktop app execPath is the Electron binary; without
243
360
  // ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
244
- 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
+ },
245
368
  },
246
369
  }
247
370
  : {},
@@ -293,10 +416,24 @@ export class Runner extends EventEmitter {
293
416
  }
294
417
  case 'change': {
295
418
  let entry = turn.changes.find((c) => c.file === ev.file);
296
- 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
+ }
297
428
  if (ev.op === 'create' && entry.edits.length === 0) entry.op = 'create';
429
+ delete entry.reverted;
298
430
  entry.edits.push(...ev.edits);
431
+ if (ev.by && !entry.by?.includes(ev.by)) (entry.by ||= []).push(ev.by);
299
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 });
300
437
  return;
301
438
  }
302
439
  case 'result':
@@ -336,15 +473,18 @@ export class Runner extends EventEmitter {
336
473
  turn.presentation = parsePresentation(ev.text);
337
474
  this.store.keepImages(session, turn);
338
475
  const hero = heroOf(turn.presentation);
339
- 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;
340
478
  }
341
479
  session.state = 'review';
342
480
  session.unread = !interrupted;
343
481
  this.store.save(session, { now: true });
344
482
  this.#touch(session, { turn: turn.id });
345
483
  this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
346
- // The follow-up starts right away; don't notify for the run it replaced.
347
- 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) });
348
488
  }
349
489
 
350
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 {
@@ -0,0 +1,85 @@
1
+ // Subagents: a team the user picks at launch (any harness, model and thinking level each) that
2
+ // the lead agent can hand work to through wisp's injected `delegate` tool. Each member is a
3
+ // hidden child session working in the lead's folder; delegating to it again resumes it.
4
+
5
+ import { blocksToMarkdown } from './render.js';
6
+
7
+ export const MAX_SUBAGENTS = 6; // rows in the team
8
+ export const MAX_COPIES = 5; // parallel copies of one row
9
+ export const MAX_INSTANCES = 12; // copies across the whole team
10
+ const EFFORT = /^[\w-]{1,20}$/;
11
+ const MAX_USE = 300;
12
+ const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
13
+
14
+ // [{ name?, agent, model?, effort?, use?, count? }] → a clean roster with unique names (codex, codex-2, …).
15
+ // use: the user's note on when to pick this one ("image work", "research"), shown to the lead.
16
+ // count: how many parallel copies of this member the lead gets (codex#1, codex#2, …).
17
+ export function normalizeTeam(list, adapters) {
18
+ if (!Array.isArray(list)) return [];
19
+ const taken = new Set();
20
+ const team = list.slice(0, MAX_SUBAGENTS).map((m) => {
21
+ const agent = String(m?.agent || '');
22
+ if (!adapters[agent] || agent === 'wisp') throw Object.assign(new Error(`unknown subagent harness: ${agent}`), { status: 400 });
23
+ let base = slug(m.name) || agent;
24
+ let name = base;
25
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
26
+ taken.add(name);
27
+ const use = String(m.use || '').replace(/\s+/g, ' ').trim().slice(0, MAX_USE);
28
+ const count = Math.min(MAX_COPIES, Math.max(1, Math.floor(Number(m.count)) || 1));
29
+ return { name, agent, model: typeof m.model === 'string' ? m.model.slice(0, 120) : '', effort: EFFORT.test(m.effort || '') ? m.effort : '', use, count };
30
+ });
31
+ const total = team.reduce((n, m) => n + m.count, 0);
32
+ if (total > MAX_INSTANCES) throw Object.assign(new Error(`At most ${MAX_INSTANCES} subagents in all (this team has ${total}).`), { status: 400 });
33
+ return team;
34
+ }
35
+
36
+ // The sessions a member runs as: itself, or one per copy (`#` can't appear in a name, so no clashes).
37
+ export const copies = (m) => ((m.count || 1) > 1 ? Array.from({ length: m.count }, (_, i) => `${m.name}#${i + 1}`) : [m.name]);
38
+
39
+ // Saved teams the user can reuse from the launcher: { id, name, members }.
40
+ export function normalizePreset(body, adapters, prev = {}) {
41
+ const name = String(body?.name ?? prev.name ?? '').trim().slice(0, 60);
42
+ if (!name) throw Object.assign(new Error('A preset needs a name.'), { status: 400 });
43
+ const members = body?.members !== undefined ? normalizeTeam(body.members, adapters) : prev.members || [];
44
+ if (!members.length) throw Object.assign(new Error('A preset needs at least one subagent.'), { status: 400 });
45
+ return { id: prev.id || slug(name) + '-' + Math.random().toString(36).slice(2, 6), name, members, updatedAt: Date.now() };
46
+ }
47
+
48
+ const who = (m, adapters) => [adapters[m.agent]?.name || m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ');
49
+
50
+ // Appended to the lead's system prompt. `tool` is how its harness names the delegate tool.
51
+ export function teamNote(team, adapters, tool) {
52
+ if (!team?.length || !tool) return '';
53
+ const rows = team
54
+ .map((m) => `- \`${m.name}\`${m.count > 1 ? ` ×${m.count} (copies ${copies(m).map((c) => `\`${c}\``).join(', ')})` : ''}: ${who(m, adapters)}${m.use ? `. Use it for: ${m.use}` : ''}`)
55
+ .join('\n');
56
+ const multi = team.find((m) => m.count > 1);
57
+ return `\n\n## Subagents
58
+
59
+ The user gave you a team of subagents, each a separate coding agent running on its own harness and model:
60
+
61
+ ${rows}
62
+
63
+ Hand them work with the \`delegate\` tool (${tool}): pass one or more tasks, \`{ "subagent": "<name>", "prompt": "…" }\`. Tasks in one call run in parallel and the call returns when all of them finish, with each subagent's report and the files it changed. With \`wait: false\` it returns at once; collect the reports later with \`subagent_results\`. Delegating to the same subagent again continues its conversation, so it remembers earlier tasks.
64
+
65
+ ${multi ? `- A subagent with ×N has N identical copies that run at once. Put several tasks for \`${multi.name}\` in one delegate call and each goes to a free copy; name a copy (\`${copies(multi)[1]}\`) to continue that copy's conversation.\n` : ''}- Subagents work in your folder and see each other's edits. Give parallel subagents separate files or concerns.
66
+ - Brief them like a colleague: goal, files, constraints, how to verify. They cannot see your conversation or ask the user.
67
+ - Follow the user's "use it for" notes when you pick a subagent: send each kind of work to the one meant for it.
68
+ - Use them when the user's request calls for it or when the work splits cleanly; you stay responsible for the result. Review what they changed before you report.
69
+ - Your presentation covers the whole task; wisp shows each subagent's report next to it, so don't repeat them in full.`;
70
+ }
71
+
72
+ // Replaces the ask_user guidance for a subagent's own system prompt.
73
+ export const SUBAGENT_ASK = `- You are a subagent: a lead agent delegated this task to you and will read your presentation.
74
+ You cannot ask the user anything. Make the call yourself, note the assumption in a "callout"
75
+ block, and finish. Don't commit unless the task says to.`;
76
+
77
+ // What the lead gets back for one delegation.
78
+ export function delegationReport(d, child, adapters) {
79
+ const turn = child?.turns.find((t) => t.id === d.turnId);
80
+ const head = `### ${d.name} (${who(d, adapters)}) — ${turn?.status || 'gone'}`;
81
+ if (!turn) return `${head}\n\nThe subagent session is gone.`;
82
+ if (turn.status === 'running') return `${head}\n\nStill working. Call subagent_results to wait for it.`;
83
+ const files = turn.changes.length ? `\n\nFiles it changed: ${turn.changes.map((c) => `\`${c.file}\``).join(', ')}` : '';
84
+ return `${head}\n\n${blocksToMarkdown(turn.presentation?.blocks || [], turn).trim() || '_No report._'}${files}`;
85
+ }