wisp-agents 0.20.0 → 0.22.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +17 -8
  3. package/bin/wisp.js +14 -6
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +12 -7
  6. package/server/adapters/codex.js +13 -7
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +22 -2
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/mcp-wisp.js +2 -1
  18. package/server/openapi.js +28 -2
  19. package/server/remote.js +4 -2
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +47 -10
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/ship.js +19 -24
  25. package/server/routes/uploads.js +2 -0
  26. package/server/runner.js +178 -12
  27. package/server/services/auto.js +10 -2
  28. package/server/services/launch.js +67 -7
  29. package/server/services/move.js +130 -0
  30. package/server/services/phone.js +20 -1
  31. package/server/services/places.js +150 -0
  32. package/server/services/ship.js +97 -10
  33. package/server/services/uploads.js +23 -4
  34. package/server/store.js +39 -6
  35. package/skills/wisp/SKILL.md +3 -1
  36. package/web/css/blocks.css +2 -0
  37. package/web/css/composer.css +23 -1
  38. package/web/css/feed.css +52 -1
  39. package/web/css/launcher.css +12 -0
  40. package/web/css/machines.css +4 -0
  41. package/web/css/modals.css +19 -0
  42. package/web/css/palette.css +6 -0
  43. package/web/css/ship.css +14 -0
  44. package/web/css/transcript.css +115 -0
  45. package/web/index.html +2 -0
  46. package/web/js/core/state.js +5 -2
  47. package/web/js/features/actions.js +42 -10
  48. package/web/js/features/btw.js +145 -0
  49. package/web/js/features/clones.js +7 -6
  50. package/web/js/features/commands.js +3 -1
  51. package/web/js/features/composer.js +162 -18
  52. package/web/js/features/feed.js +10 -10
  53. package/web/js/features/keys.js +2 -1
  54. package/web/js/features/launcher.js +121 -67
  55. package/web/js/features/place-menu.js +44 -0
  56. package/web/js/features/places.js +25 -5
  57. package/web/js/features/project-list.js +20 -5
  58. package/web/js/features/projects.js +16 -13
  59. package/web/js/features/settings.js +24 -3
  60. package/web/js/features/ship.js +66 -29
  61. package/web/js/features/slash.js +63 -0
  62. package/web/js/features/transcript.js +290 -0
  63. package/web/js/features/turns.js +101 -29
  64. package/web/js/slash-text.js +22 -0
  65. package/web/js/ui/kbd.js +1 -0
@@ -1,7 +1,9 @@
1
1
  // Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
2
- // commit, push, open or update the PR. Then follow it (pr) and squash-merge it (merge).
2
+ // commit, push, open or update the PR. Then follow it (pr) and squash-merge it (merge). A task in
3
+ // several repos (also) ships, follows and merges a PR in each (services/ship.js).
3
4
 
4
- import { ship, merge, worktreeStatus } from '../git.js';
5
+ import { worktreeStatus } from '../git.js';
6
+ import { reposOf } from '../services/ship.js';
5
7
 
6
8
  export function register(r, app) {
7
9
  const { store, runner, telemetry } = app;
@@ -11,46 +13,40 @@ export function register(r, app) {
11
13
  return fn(c, s);
12
14
  };
13
15
  const NO_TREE = 'Ship it works on worktree sessions: launch with a worktree to get one.';
16
+ const hasTrees = (s) => !!(s.worktree || s.also?.some((a) => a.worktree));
14
17
 
15
18
  r.route(
16
19
  'GET',
17
20
  '/sessions/:id/ship',
18
21
  one(async (c, s) => {
19
- if (!s.worktree) return c.send(400, { error: NO_TREE });
22
+ if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
20
23
  app.ship.recheck(s);
21
- c.send(200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...app.ship.draft(s) });
24
+ const repos = await Promise.all(reposOf(s, store).map(async (r) => ({ repo: r.name, branch: r.wt.branch, base: r.wt.base, remote: r.wt.remote, ...(await worktreeStatus(r.wt)), pr: r.at.pr || null })));
25
+ // The first repo's fields at the top level (as before one task could span repos), each in `repos`.
26
+ const sum = (k) => repos.reduce((n, r) => n + r[k], 0);
27
+ c.send(200, { ...repos[0], ...(repos.length > 1 && { dirty: sum('dirty'), ahead: sum('ahead'), repos }), ...app.ship.draft(s) });
22
28
  }),
23
29
  );
24
30
  r.route(
25
31
  'POST',
26
32
  '/sessions/:id/ship',
27
33
  one(async (c, s) => {
28
- if (!s.worktree) return c.send(400, { error: NO_TREE });
34
+ if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
29
35
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
30
36
  if (s.shipping) return c.send(409, { error: 'Already shipping: wait for it to land.' });
31
37
  const body = await c.body();
32
38
  const draft = app.ship.draft(s);
33
- // The card follows along: s.shipping names the stage running now, sent with each summary.
34
- s.shipping = { step: 'start', update: s.pr?.state === 'OPEN', startedAt: Date.now() };
35
- app.sessionChanged(s);
36
- const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
37
- try {
38
- s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr, onStep });
39
- s.pushedAt = Date.now(); // the PR is polled fast for a while, so its checks show up as they start
40
- } finally {
41
- delete s.shipping;
42
- app.sessionChanged(s, { now: true });
43
- }
44
- telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
45
- app.ship.recheck(s);
46
- c.send(200, s.pr);
39
+ // The card follows along: s.shipping names the stage (and repo) running now, sent with each summary.
40
+ const pr = await app.ship.shipAll(s, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft });
41
+ telemetry.track('ship', { agent: s.agent, draft: !!body.draft, ...(pr.prs && { repos: pr.prs.length }) });
42
+ c.send(200, pr);
47
43
  }),
48
44
  );
49
45
  r.route(
50
46
  'POST',
51
47
  '/sessions/:id/pr',
52
48
  one(async (c, s) => {
53
- if (!s.pr) return c.send(404, { error: 'No pull request yet.' });
49
+ if (!s.pr && !s.also?.some((a) => a.pr)) return c.send(404, { error: 'No pull request yet.' });
54
50
  c.send(200, await app.ship.refreshPr(s));
55
51
  }),
56
52
  );
@@ -76,13 +72,12 @@ export function register(r, app) {
76
72
  'POST',
77
73
  '/sessions/:id/merge',
78
74
  one(async (c, s) => {
79
- if (!s.pr || !s.worktree) return c.send(400, { error: 'No pull request to merge.' });
80
- const { pr, sync } = await merge(s.worktree, s.pr.number);
81
- s.pr = pr;
75
+ if (!reposOf(s, store).some((r) => r.at.pr)) return c.send(400, { error: 'No pull request to merge.' });
76
+ const out = await app.ship.mergeAll(s);
82
77
  app.launcher.markDone(s);
83
78
  telemetry.track('merge', { agent: s.agent });
84
79
  app.sessionChanged(s, { now: true });
85
- c.send(200, { pr, sync });
80
+ c.send(200, out);
86
81
  }),
87
82
  );
88
83
  }
@@ -16,4 +16,6 @@ export function register(r, app) {
16
16
  c.res.writeHead(200, { 'content-type': u.mime, 'content-length': fs.statSync(u.path).size });
17
17
  fs.createReadStream(u.path).pipe(c.res);
18
18
  });
19
+ // A moved task's bundle, once the other place has it (services/move.js).
20
+ r.route('DELETE', '/uploads/:id', (c) => c.send(200, { ok: uploads.remove(c.params.id) }), { doc: false });
19
21
  }
package/server/runner.js CHANGED
@@ -12,9 +12,14 @@ import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js
12
12
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
13
13
  import { handoffNote } from './handoff.js';
14
14
  import { agentPrompt, plainMentions } from './mentions.js';
15
- import { bad, MODES, newId } from './util.js';
15
+ import { BTW_SYSTEM, BTW_TIMEOUT_MS, MAX_BTW, btwAnswer, btwPrompt } from './btw.js';
16
+ import { bad, clip, MODES, newId } from './util.js';
16
17
 
17
18
  const MAX_ACTIVITY = 400;
19
+ const MAX_TEXT = 20_000; // an agent message, kept whole for the transcript up to this
20
+ const MAX_OUTPUT = 2_000; // what a tool call returned: its start and end past this
21
+ // The calls whose output the transcript shows. A read returns the file and an edit a confirmation.
22
+ const OUTPUT_KINDS = new Set(['run', 'search', 'web', 'tool', 'agent']);
18
23
  const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
19
24
  const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
20
25
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
@@ -43,14 +48,38 @@ function retryPrompt(items, scoped) {
43
48
 
44
49
  export { newId };
45
50
 
51
+ // A tool call's output for the transcript: whole when short, else its first and last lines (the
52
+ // end of a test run or a build is what tells how it went).
53
+ export function clipOutput(text) {
54
+ const out = String(text ?? '').replace(/\s+$/, '');
55
+ if (out.length <= MAX_OUTPUT) return out;
56
+ let head = out.slice(0, 600);
57
+ let tail = out.slice(-1300);
58
+ if (head.includes('\n')) head = head.slice(0, head.lastIndexOf('\n'));
59
+ if (tail.includes('\n')) tail = tail.slice(tail.indexOf('\n') + 1);
60
+ const cut = out.slice(head.length, out.length - tail.length);
61
+ const lines = cut.split('\n').length - 1;
62
+ return `${head}\n… ${lines > 1 ? `${lines} lines` : `${cut.length} characters`} cut …\n${tail}`;
63
+ }
64
+
46
65
  // Where the agent actually works: its own worktree when it has one, else the project folder.
47
66
  export const workdir = (s) => s.worktree?.cwd || s.cwd;
48
67
 
49
- // Worktree sessions get told where they are, so they don't go looking for the main checkout.
68
+ // Where the agent works in the task's other folders (also): their worktrees, else the folders.
69
+ export const alsoDirs = (s) => (s.also || []).map((a) => a.worktree?.cwd || a.cwd);
70
+
71
+ // Worktree sessions get told where they are, so they don't go looking for the main checkout. A task
72
+ // with other folders (also) gets their paths: that's where the work in them goes.
50
73
  function workspaceNote(s) {
51
74
  const wt = s.worktree;
52
- if (!wt) return '';
53
- 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).`;
75
+ const also = s.also || [];
76
+ const out = [];
77
+ if (wt) out.push(`You 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).`);
78
+ if (also.length) {
79
+ const line = (a) => (a.worktree ? `- \`${a.worktree.cwd}\`: a worktree of \`${a.cwd}\` on branch \`${a.worktree.branch}\` (from \`${a.worktree.baseRef}\`). Work here, not in \`${a.cwd}\`.` : `- \`${a.cwd}\``);
80
+ out.push(`This task spans ${also.length + 1} folders. Besides your working directory (${workdir(s)}), you work in:\n${also.map(line).join('\n')}\n\nRead and change files in all of them as the task needs, using absolute paths outside your working directory. Run each folder's commands (tests, builds) from inside that folder.${also.some((a) => a.worktree) ? ' Leave the changes there uncommitted too; Ship it opens a pull request per repo.' : ''}`);
81
+ }
82
+ return out.length ? `\n\n## Workspace\n\n${out.join('\n\n')}` : '';
54
83
  }
55
84
 
56
85
  // A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
@@ -102,6 +131,13 @@ export function summarize(s, store) {
102
131
  question: s.ask?.question || '',
103
132
  approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
104
133
  branch: s.worktree?.branch || '',
134
+ // The task's other folders: their project, worktree branch and PR, for the card and the filter.
135
+ ...(s.also?.length && {
136
+ also: s.also.map((a) => {
137
+ const p = store?.projectFor(a.cwd);
138
+ return { cwd: a.cwd, project: p?.name || path.basename(a.cwd), projectId: p?.id || null, color: p?.color || 'slate', branch: a.worktree?.branch || '', pr: a.pr ? { number: a.pr.number, url: a.pr.url, state: a.pr.state, draft: a.pr.draft, review: a.pr.review, checks: a.pr.checks, conflict: !!a.pr.conflict } : null };
139
+ }),
140
+ }),
105
141
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
106
142
  shipping: s.shipping || null, // { step: 'start' | 'commit' | 'push' | 'pr', update } while Ship it runs
107
143
  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, conflict: !!s.pr.conflict } : null,
@@ -109,6 +145,8 @@ export function summarize(s, store) {
109
145
  conflictFix: s.conflictFix ? { step: s.conflictFix.step, why: s.conflictFix.why || '' } : null,
110
146
  groupId: s.groupId || null,
111
147
  origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
148
+ moved: s.moved || null, // { from }: carried on here from another place (services/move.js)
149
+ movedTo: s.movedTo || null, // { id, place }: this one carried on there, and was archived
112
150
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
113
151
  team: (s.subagents || []).flatMap((m) =>
114
152
  copies(m).map((name) => {
@@ -147,6 +185,7 @@ export class Runner extends EventEmitter {
147
185
  this.stopping = new Set();
148
186
  this.interrupting = new Set();
149
187
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
188
+ this.sides = new Map(); // /btw id -> { sessionId, handle }: side questions being answered
150
189
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
151
190
  this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
152
191
  this.pipeline = null; // Auto's check / repair / hand-over steps (server/pipeline.js)
@@ -161,17 +200,22 @@ export class Runner extends EventEmitter {
161
200
  s.state = 'review';
162
201
  store.save(s);
163
202
  }
203
+ const asked = s.turns.flatMap((x) => x.btw || []).filter((b) => b.status === 'running');
204
+ for (const b of asked) Object.assign(b, { status: 'error', error: 'wisp was restarted before this was answered.' });
205
+ if (asked.length) store.save(s);
164
206
  }
165
207
  }
166
208
 
167
209
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
168
- create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null }) {
210
+ // also: the task's other folders, [{ cwd, worktree }] (services/launch.js).
211
+ // history / title / moved: a task moved here from another place, with the turns it had there.
212
+ create({ id = newId(), prompt, cwd, also = [], agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null, history = [], title = '', moved = null }) {
169
213
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
170
214
  const now = Date.now();
171
215
  const session = {
172
216
  id,
173
- title: autoTitle(prompt),
174
- titleAuto: true,
217
+ title: title || autoTitle(prompt),
218
+ titleAuto: !title,
175
219
  agent,
176
220
  cwd,
177
221
  mode,
@@ -184,8 +228,10 @@ export class Runner extends EventEmitter {
184
228
  agentSessionId: null,
185
229
  agentSessions: {}, // harness -> its own conversation id, so switching back resumes it
186
230
  queue: [],
187
- turns: [],
231
+ turns: [...history],
232
+ ...(moved && { moved }),
188
233
  ...(worktree && { worktree }),
234
+ ...(also.length && { also }),
189
235
  ...(groupId && { groupId }),
190
236
  ...(origin && { origin }),
191
237
  ...(subagents.length && { subagents, children: {} }),
@@ -195,7 +241,7 @@ export class Runner extends EventEmitter {
195
241
  ...(route && { route }), // how Auto picked agent, model and level (server/router.js)
196
242
  };
197
243
  this.store.put(session);
198
- if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
244
+ if (!parentId) for (const dir of [...also.map((a) => a.cwd), cwd]) this.store.touchProject(dir); // a subagent's cwd may be the lead's worktree
199
245
  this.#startTurn(session, prompt, attachments);
200
246
  return session;
201
247
  }
@@ -289,6 +335,99 @@ export class Runner extends EventEmitter {
289
335
  return true;
290
336
  }
291
337
 
338
+ // /btw: a side question, answered by a read-only run of its own while the task's run carries on
339
+ // (server/btw.js). It lands on the latest turn and never reaches the task's conversation.
340
+ btw(session, question, attachments = []) {
341
+ const turn = session.turns.at(-1);
342
+ if (!turn) throw bad('Nothing to ask about yet.', 409);
343
+ if ([...this.sides.values()].filter((x) => x.sessionId === session.id).length >= MAX_BTW) throw bad(`${MAX_BTW} side questions are still being answered. Ask again once one is in.`, 429);
344
+ const entry = {
345
+ id: newId(),
346
+ question,
347
+ status: 'running',
348
+ askedAt: Date.now(),
349
+ agent: session.agent,
350
+ model: session.model || '',
351
+ ...(attachments.length && { attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })) }),
352
+ };
353
+ (turn.btw ||= []).push(entry);
354
+ this.#sideRun(session, turn, entry, attachments, !!adapters[session.agent]?.fork && !!session.agentSessionId);
355
+ this.#btwChanged(session, turn);
356
+ return entry;
357
+ }
358
+
359
+ // forked: on a copy of the agent's conversation, so it knows what the run did and why. A fork
360
+ // that fails (a CLI too old to fork) asks again with wisp's record of the task instead.
361
+ #sideRun(session, turn, entry, attachments, forked) {
362
+ const adapter = adapters[entry.agent];
363
+ const name = (t) => adapters[t.agent || session.agent]?.name || t.agent || session.agent;
364
+ const question = agentPrompt(entry.question, this.store, { tool: null, self: session.id });
365
+ entry.forked = forked;
366
+ let reply = null;
367
+ let handle;
368
+ try {
369
+ handle = adapter.run({
370
+ prompt: btwPrompt(session, question, { forked, name }),
371
+ attachments,
372
+ cwd: workdir(session),
373
+ dirs: alsoDirs(session),
374
+ resumeId: forked ? session.agentSessionId : null,
375
+ fork: forked,
376
+ mode: 'plan',
377
+ model: entry.model,
378
+ effort: session.effort || '',
379
+ systemPrompt: BTW_SYSTEM,
380
+ onEvent: (ev) => {
381
+ if (ev.type === 'result') reply = ev;
382
+ },
383
+ });
384
+ } catch (err) {
385
+ reply = { isError: true, text: err.message };
386
+ handle = { done: Promise.resolve(), stop() {} };
387
+ }
388
+ const side = { sessionId: session.id, handle, timedOut: false };
389
+ this.sides.set(entry.id, side);
390
+ const timer = setTimeout(() => ((side.timedOut = true), handle.stop()), BTW_TIMEOUT_MS);
391
+ handle.done.then(() => {
392
+ clearTimeout(timer);
393
+ if (this.sides.get(entry.id) !== side) return; // dismissed, or the task was deleted
394
+ this.sides.delete(entry.id);
395
+ const ok = reply && !reply.isError && !reply.stopped;
396
+ if (!ok && forked && !side.timedOut) return this.#sideRun(session, turn, entry, attachments, false);
397
+ if (ok) Object.assign(entry, { status: 'done', answer: btwAnswer(reply.text), ...(reply.stats?.costUsd && { costUsd: reply.stats.costUsd }) });
398
+ else Object.assign(entry, { status: 'error', error: side.timedOut ? `No answer after ${BTW_TIMEOUT_MS / 60_000} minutes.` : clip(reply?.text || 'The side run ended without an answer.', 400) });
399
+ entry.answeredAt = Date.now();
400
+ this.#btwChanged(session, turn);
401
+ });
402
+ }
403
+
404
+ // Dismiss a side question, stopping its run if it's still going.
405
+ dropBtw(session, id) {
406
+ const turn = session.turns.find((t) => t.btw?.some((b) => b.id === id));
407
+ if (!turn) return false;
408
+ turn.btw = turn.btw.filter((b) => b.id !== id);
409
+ if (!turn.btw.length) delete turn.btw;
410
+ this.sides.get(id)?.handle.stop();
411
+ this.sides.delete(id);
412
+ this.#btwChanged(session, turn);
413
+ return true;
414
+ }
415
+
416
+ // The task is going away: its side questions stop too.
417
+ stopSides(session) {
418
+ for (const [id, side] of this.sides) {
419
+ if (side.sessionId !== session.id) continue;
420
+ this.sides.delete(id);
421
+ side.handle.stop();
422
+ }
423
+ }
424
+
425
+ // A side question changed: the card repaints it in place. The task's own state is untouched.
426
+ #btwChanged(session, turn) {
427
+ this.store.save(session);
428
+ this.emit('turn', { sessionId: session.id, turnId: turn.id });
429
+ }
430
+
292
431
  // ask_user: the agent is blocked inside a tool call until answer() is called.
293
432
  // permission: set for a live approval ({ tool, action, detail }), see requestApproval().
294
433
  ask(session, { question, context, options, permission }) {
@@ -519,6 +658,14 @@ export class Runner extends EventEmitter {
519
658
  const mode = MODES.has(once) ? once : session.mode;
520
659
  const turn = this.#newTurn(session, prompt, attachments, mode);
521
660
  if (by === 'wisp') turn.by = 'wisp'; // sent by wisp (Auto's repair round), not by you
661
+ // Side questions still being answered move to the new turn: the old one folds away on the card.
662
+ const prev = session.turns.at(-1);
663
+ const open = prev?.btw?.filter((b) => b.status === 'running') || [];
664
+ if (open.length) {
665
+ prev.btw = prev.btw.filter((b) => !open.includes(b));
666
+ if (!prev.btw.length) delete prev.btw;
667
+ turn.btw = open;
668
+ }
522
669
  session.turns.push(turn);
523
670
  session.state = 'running';
524
671
  session.unread = false;
@@ -541,6 +688,7 @@ export class Runner extends EventEmitter {
541
688
  prompt: handoff ? `${handoff}\n\n${text}` : text,
542
689
  attachments,
543
690
  cwd: workdir(session),
691
+ dirs: alsoDirs((session.parentId && this.store.get(session.parentId)) || session), // the task's other folders (a subagent's are its lead's)
544
692
  resumeId: session.agentSessionId,
545
693
  mode,
546
694
  grants,
@@ -596,15 +744,29 @@ export class Runner extends EventEmitter {
596
744
  case 'tool_error':
597
745
  case 'text': {
598
746
  const item = { t: Date.now(), kind: ev.type === 'tool' ? ev.kind : ev.type === 'text' ? 'text' : 'error' };
599
- if (ev.type === 'tool') Object.assign(item, { label: ev.label, file: ev.file, detail: ev.detail });
600
- else if (ev.type === 'text') item.label = ev.text.slice(0, 600);
747
+ if (ev.type === 'tool') {
748
+ Object.assign(item, { label: ev.label, file: ev.file, detail: ev.detail });
749
+ if (ev.id) item.call = String(ev.id); // a tool_output later finds its call by this
750
+ if (ev.output && OUTPUT_KINDS.has(item.kind)) item.output = clipOutput(ev.output);
751
+ } else if (ev.type === 'text') item.label = ev.text.slice(0, MAX_TEXT);
601
752
  else item.label = `${ev.name || 'Tool'} failed: ${ev.text}`;
602
753
  turn.activity.push(item);
603
- if (turn.activity.length > MAX_ACTIVITY) turn.activity.splice(0, turn.activity.length - MAX_ACTIVITY);
754
+ // The transcript says how many of the oldest steps it no longer has.
755
+ if (turn.activity.length > MAX_ACTIVITY) turn.trimmed = (turn.trimmed || 0) + turn.activity.splice(0, turn.activity.length - MAX_ACTIVITY).length;
604
756
  this.emit('activity', { sessionId: session.id, turnId: turn.id, item });
605
757
  this.#touch(session, { quiet: ev.type === 'text' });
606
758
  return;
607
759
  }
760
+ case 'tool_output': {
761
+ // What a call returned, for the transcript: matched to the call by the harness's id.
762
+ const item = ev.id && turn.activity.findLast((a) => a.call === String(ev.id));
763
+ const output = clipOutput(ev.text);
764
+ if (!item || !OUTPUT_KINDS.has(item.kind) || !output.trim()) return;
765
+ item.output = output;
766
+ this.emit('activity', { sessionId: session.id, turnId: turn.id, item, update: true });
767
+ this.store.save(session);
768
+ return;
769
+ }
608
770
  case 'change': {
609
771
  let entry = turn.changes.find((c) => c.file === ev.file);
610
772
  if (!entry) {
@@ -618,7 +780,11 @@ export class Runner extends EventEmitter {
618
780
  }
619
781
  if (ev.op === 'create' && entry.edits.length === 0) entry.op = 'create';
620
782
  delete entry.reverted;
783
+ const from = entry.edits.length;
621
784
  entry.edits.push(...ev.edits);
785
+ // The transcript shows each edit under the call that made it: [from, to) in entry.edits.
786
+ const call = !ev.by && turn.activity.findLast((a) => a.kind === 'edit' && a.file === ev.file);
787
+ if (call) call.edits = [call.edits?.[0] ?? from, entry.edits.length];
622
788
  if (ev.by && !entry.by?.includes(ev.by)) (entry.by ||= []).push(ev.by);
623
789
  this.store.save(session);
624
790
  // A subagent's edits are the lead's too: they show up in its files, diffs and Ship it.
@@ -109,12 +109,20 @@ export function createAuto(app) {
109
109
  const first = s.turns[0];
110
110
  const id = newId();
111
111
  if (!s.groupId) s.groupId = newId();
112
- const wt = s.worktree;
113
- const worktree = wt ? await createWorktree({ cwd: s.cwd, home: store.home, id, title: first.prompt, source: { root: wt.root, base: { ref: wt.head || wt.baseRef, branch: wt.base, remote: wt.remote, sha: wt.head } } }).catch(() => null) : null;
112
+ const fresh = (wt, cwd) => (wt ? createWorktree({ cwd, home: store.home, id, title: first.prompt, source: { root: wt.root, base: { ref: wt.head || wt.baseRef, branch: wt.base, remote: wt.remote, sha: wt.head } } }).catch(() => null) : null);
113
+ const worktree = await fresh(s.worktree, s.cwd);
114
+ // The task's other folders too: fresh worktrees from the same bases.
115
+ const also = await Promise.all(
116
+ (s.also || []).map(async (a) => {
117
+ const tree = await fresh(a.worktree, a.cwd);
118
+ return tree ? { cwd: a.cwd, worktree: tree } : { cwd: a.cwd };
119
+ }),
120
+ );
114
121
  const sib = app.runner.create({
115
122
  id,
116
123
  prompt: first.prompt,
117
124
  cwd: s.cwd,
125
+ also,
118
126
  agent: pick.agent,
119
127
  model: pick.model,
120
128
  effort: pick.effort || '',
@@ -2,7 +2,9 @@
2
2
  // runs), here or on a machine, plus the follow-ups and "done" that every surface shares.
3
3
 
4
4
  import fs from 'node:fs';
5
+ import path from 'node:path';
5
6
  import { adapters } from '../adapters/index.js';
7
+ import { btwQuestion } from '../btw.js';
6
8
  import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
7
9
  import { qualify } from '../machines.js';
8
10
  import { fill, positional } from '../recipes.js';
@@ -11,18 +13,40 @@ import { normalizeTeam } from '../subagents.js';
11
13
  import { bad, expand, MODES, newId } from '../util.js';
12
14
 
13
15
  export const MAX_COMPARE = 4;
16
+ export const MAX_ALSO = 8; // extra folders one task may work in
17
+
18
+ // also: more folders the task works in besides cwd (a backend and its web app, say). Each must be
19
+ // a directory; repeats, cwd itself and folders inside one already listed are dropped.
20
+ export function alsoDirs(list, cwd) {
21
+ if (list === undefined || list === null) return [];
22
+ if (!Array.isArray(list)) throw bad('also must be a list of folders.');
23
+ const inside = (a, b) => a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep);
24
+ const out = [];
25
+ for (const x of list) {
26
+ const raw = typeof x === 'string' ? x : x?.cwd;
27
+ if (!String(raw || '').trim()) continue;
28
+ const dir = path.resolve(expand(String(raw).trim()));
29
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw bad(`Not a directory: ${dir}`);
30
+ if (inside(dir, cwd) || out.some((d) => inside(dir, d))) continue;
31
+ for (let i = out.length - 1; i >= 0; i--) if (inside(out[i], dir)) out.splice(i, 1);
32
+ out.push(dir);
33
+ }
34
+ if (out.length > MAX_ALSO) throw bad(`A task can work in up to ${MAX_ALSO + 1} folders.`);
35
+ return out;
36
+ }
14
37
 
15
38
  export function createLaunch(app) {
16
39
  const { store, runner, machines } = app;
17
40
 
18
- // body: { prompt, cwd, scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
19
- // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
41
+ // body: { prompt, cwd, also: [folder], scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
42
+ // subagents: [{ name, agent, model, effort, use }] | preset: teamId, moved: { from, title, turns, packs } }
20
43
  async function launch(body, origin = null) {
21
44
  if (!String(body.prompt || '').trim()) throw bad('prompt required');
22
45
  // scratch: no project folder (general questions); the task gets an empty directory of its own.
23
46
  const scratch = body.scratch === true;
24
47
  const cwd = scratch ? store.scratchDir(newId()) : expand(body.cwd || process.cwd());
25
48
  if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
49
+ const also = scratch ? [] : alsoDirs(body.also, cwd);
26
50
  const d = store.config.defaults;
27
51
  const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
28
52
  if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
@@ -49,6 +73,16 @@ export function createLaunch(app) {
49
73
  const attachments = app.uploads.resolve(body.attachments);
50
74
  const ids = runners.map(() => newId());
51
75
  let trees = ids.map(() => null);
76
+ // A task moved here from another place (services/move.js): its work comes in its bundles.
77
+ const moved = body.moved && typeof body.moved === 'object' && !comparing ? body.moved : null;
78
+ if (moved) {
79
+ const made = await app.mover.unpack([cwd, ...also], moved.packs, { id: ids[0], title: moved.title || body.prompt });
80
+ trees = [made[0]];
81
+ const extras = also.map((dir, i) => ({ cwd: dir, ...(made[i + 1] && { worktree: made[i + 1] }) }));
82
+ const history = Array.isArray(moved.turns) ? moved.turns.filter((t) => t && typeof t === 'object' && typeof t.prompt === 'string') : [];
83
+ const r = runners[0];
84
+ return [runner.create({ id: ids[0], prompt: body.prompt, cwd, also: extras, ...r, mode, attachments, worktree: trees[0], groupId, origin: origin || moved.origin || null, subagents, history, title: moved.title, moved: { from: String(moved.from || ''), at: Date.now() } })];
85
+ }
52
86
  if (wantTree) {
53
87
  const must = body.worktree === true || (comparing && mode !== 'plan');
54
88
  // One fetch for the whole launch, then the checkouts side by side: compare runs used to
@@ -66,12 +100,22 @@ export function createLaunch(app) {
66
100
  }
67
101
  if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
68
102
  }
69
- return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
103
+ // The other folders: a worktree of their own per run where they're git repos (so parallel
104
+ // tasks don't collide there either), else the folder itself.
105
+ const extras = await Promise.all(
106
+ also.map(async (dir) => {
107
+ const source = wantTree && (await worktreeSource(dir).catch(() => null));
108
+ const made = await Promise.all(ids.map((id) => (source ? createWorktree({ cwd: dir, home: store.home, id, title: body.prompt, source }).catch((err) => console.warn(`wisp: no worktree for ${dir}: ${err.message}`)) : null)));
109
+ return made.map((worktree) => ({ cwd: dir, ...(worktree && { worktree }) }));
110
+ }),
111
+ );
112
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, also: extras.map((x) => x[i]), ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
70
113
  }
71
114
 
72
- // Where a launch runs: body.machine, else a project folder that only exists on a machine
73
- // (recipes and schedules keep just the path). null = here.
115
+ // Where a launch runs: body.machine ('' = here), else a project folder that only exists on a
116
+ // machine (recipes and schedules keep just the path). null = here.
74
117
  function machineFor(body) {
118
+ if (typeof body.machine === 'string') return body.machine ? machines.get(body.machine).id : null; // '' = this computer
75
119
  if (body.machine) return machines.get(String(body.machine)).id;
76
120
  const cwd = String(body.cwd || '');
77
121
  if (!cwd || fs.existsSync(expand(cwd))) return null;
@@ -88,6 +132,8 @@ export function createLaunch(app) {
88
132
  // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
89
133
  async function launchAny(body, origin = null) {
90
134
  const mid = machineFor(body);
135
+ // Every project runs anywhere: its folders become the ones on that place, cloned there first if need be.
136
+ body = await app.places.placeBody(body, mid);
91
137
  if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
92
138
  // Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
93
139
  // back through the summaries it streams (auto.signals), so one router learns from every place.
@@ -109,11 +155,25 @@ export function createLaunch(app) {
109
155
  }
110
156
 
111
157
  // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
158
+ // One that starts with /btw is a side question instead: the run carries on (see askBtw).
112
159
  async function replyTo(s, text, attachments = [], queue, mode, run) {
113
- await ensureWorktree(s.worktree);
160
+ const question = btwQuestion(text);
161
+ if (question !== null) return { btw: await askBtw(s, question, attachments) };
162
+ for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
114
163
  return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
115
164
  }
116
165
 
166
+ // /btw: answered by a read-only run of its own, next to the task's run (server/btw.js).
167
+ async function askBtw(s, question, attachments = []) {
168
+ question = String(question || '').trim();
169
+ if (!question) throw bad('Type your question after /btw.');
170
+ if (s.agent === 'wisp') throw bad('This is the tour — launch a real wisp to ask it things.');
171
+ for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
172
+ const entry = runner.btw(s, question, attachments);
173
+ app.telemetry?.track('btw', { agent: s.agent, forked: entry.forked, running: runner.live.has(s.id) });
174
+ return entry;
175
+ }
176
+
117
177
  function markDone(s) {
118
178
  if (s.state === 'running' || s.state === 'waiting') return false;
119
179
  s.state = 'done';
@@ -132,5 +192,5 @@ export function createLaunch(app) {
132
192
  return s;
133
193
  }
134
194
 
135
- return { launch, launchAny, machineFor, pushUploads, replyTo, markDone, runRecipe };
195
+ return { launch, launchAny, machineFor, pushUploads, replyTo, askBtw, markDone, runRecipe };
136
196
  }