wisp-agents 0.21.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 (57) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +13 -8
  3. package/bin/wisp.js +10 -3
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +8 -4
  6. package/server/adapters/codex.js +9 -6
  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 +19 -0
  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/openapi.js +27 -2
  18. package/server/remote.js +4 -2
  19. package/server/routes/machines.js +1 -1
  20. package/server/routes/proxy.js +6 -0
  21. package/server/routes/sessions.js +32 -2
  22. package/server/routes/settings.js +24 -38
  23. package/server/routes/uploads.js +2 -0
  24. package/server/runner.js +154 -8
  25. package/server/services/launch.js +32 -4
  26. package/server/services/move.js +130 -0
  27. package/server/services/phone.js +20 -1
  28. package/server/services/places.js +150 -0
  29. package/server/services/uploads.js +23 -4
  30. package/server/store.js +39 -6
  31. package/skills/wisp/SKILL.md +1 -0
  32. package/web/css/blocks.css +2 -0
  33. package/web/css/composer.css +23 -1
  34. package/web/css/feed.css +49 -1
  35. package/web/css/machines.css +4 -0
  36. package/web/css/modals.css +19 -0
  37. package/web/css/ship.css +2 -0
  38. package/web/css/transcript.css +115 -0
  39. package/web/index.html +2 -0
  40. package/web/js/core/state.js +3 -2
  41. package/web/js/features/actions.js +42 -10
  42. package/web/js/features/btw.js +145 -0
  43. package/web/js/features/clones.js +7 -6
  44. package/web/js/features/commands.js +3 -1
  45. package/web/js/features/composer.js +162 -18
  46. package/web/js/features/feed.js +1 -1
  47. package/web/js/features/keys.js +2 -1
  48. package/web/js/features/launcher.js +104 -94
  49. package/web/js/features/place-menu.js +44 -0
  50. package/web/js/features/places.js +15 -5
  51. package/web/js/features/projects.js +14 -12
  52. package/web/js/features/settings.js +24 -3
  53. package/web/js/features/slash.js +63 -0
  54. package/web/js/features/transcript.js +290 -0
  55. package/web/js/features/turns.js +99 -28
  56. package/web/js/slash-text.js +22 -0
  57. package/web/js/ui/kbd.js +1 -0
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,6 +48,20 @@ 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
 
@@ -126,6 +145,8 @@ export function summarize(s, store) {
126
145
  conflictFix: s.conflictFix ? { step: s.conflictFix.step, why: s.conflictFix.why || '' } : null,
127
146
  groupId: s.groupId || null,
128
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
129
150
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
130
151
  team: (s.subagents || []).flatMap((m) =>
131
152
  copies(m).map((name) => {
@@ -164,6 +185,7 @@ export class Runner extends EventEmitter {
164
185
  this.stopping = new Set();
165
186
  this.interrupting = new Set();
166
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
167
189
  this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
168
190
  this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
169
191
  this.pipeline = null; // Auto's check / repair / hand-over steps (server/pipeline.js)
@@ -178,18 +200,22 @@ export class Runner extends EventEmitter {
178
200
  s.state = 'review';
179
201
  store.save(s);
180
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);
181
206
  }
182
207
  }
183
208
 
184
209
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
185
210
  // also: the task's other folders, [{ cwd, worktree }] (services/launch.js).
186
- 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 }) {
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 }) {
187
213
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
188
214
  const now = Date.now();
189
215
  const session = {
190
216
  id,
191
- title: autoTitle(prompt),
192
- titleAuto: true,
217
+ title: title || autoTitle(prompt),
218
+ titleAuto: !title,
193
219
  agent,
194
220
  cwd,
195
221
  mode,
@@ -202,7 +228,8 @@ export class Runner extends EventEmitter {
202
228
  agentSessionId: null,
203
229
  agentSessions: {}, // harness -> its own conversation id, so switching back resumes it
204
230
  queue: [],
205
- turns: [],
231
+ turns: [...history],
232
+ ...(moved && { moved }),
206
233
  ...(worktree && { worktree }),
207
234
  ...(also.length && { also }),
208
235
  ...(groupId && { groupId }),
@@ -308,6 +335,99 @@ export class Runner extends EventEmitter {
308
335
  return true;
309
336
  }
310
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
+
311
431
  // ask_user: the agent is blocked inside a tool call until answer() is called.
312
432
  // permission: set for a live approval ({ tool, action, detail }), see requestApproval().
313
433
  ask(session, { question, context, options, permission }) {
@@ -538,6 +658,14 @@ export class Runner extends EventEmitter {
538
658
  const mode = MODES.has(once) ? once : session.mode;
539
659
  const turn = this.#newTurn(session, prompt, attachments, mode);
540
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
+ }
541
669
  session.turns.push(turn);
542
670
  session.state = 'running';
543
671
  session.unread = false;
@@ -616,15 +744,29 @@ export class Runner extends EventEmitter {
616
744
  case 'tool_error':
617
745
  case 'text': {
618
746
  const item = { t: Date.now(), kind: ev.type === 'tool' ? ev.kind : ev.type === 'text' ? 'text' : 'error' };
619
- if (ev.type === 'tool') Object.assign(item, { label: ev.label, file: ev.file, detail: ev.detail });
620
- 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);
621
752
  else item.label = `${ev.name || 'Tool'} failed: ${ev.text}`;
622
753
  turn.activity.push(item);
623
- 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;
624
756
  this.emit('activity', { sessionId: session.id, turnId: turn.id, item });
625
757
  this.#touch(session, { quiet: ev.type === 'text' });
626
758
  return;
627
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
+ }
628
770
  case 'change': {
629
771
  let entry = turn.changes.find((c) => c.file === ev.file);
630
772
  if (!entry) {
@@ -638,7 +780,11 @@ export class Runner extends EventEmitter {
638
780
  }
639
781
  if (ev.op === 'create' && entry.edits.length === 0) entry.op = 'create';
640
782
  delete entry.reverted;
783
+ const from = entry.edits.length;
641
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];
642
788
  if (ev.by && !entry.by?.includes(ev.by)) (entry.by ||= []).push(ev.by);
643
789
  this.store.save(session);
644
790
  // A subagent's edits are the lead's too: they show up in its files, diffs and Ship it.
@@ -4,6 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { adapters } from '../adapters/index.js';
7
+ import { btwQuestion } from '../btw.js';
7
8
  import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
8
9
  import { qualify } from '../machines.js';
9
10
  import { fill, positional } from '../recipes.js';
@@ -38,7 +39,7 @@ export function createLaunch(app) {
38
39
  const { store, runner, machines } = app;
39
40
 
40
41
  // body: { prompt, cwd, also: [folder], scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
41
- // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
42
+ // subagents: [{ name, agent, model, effort, use }] | preset: teamId, moved: { from, title, turns, packs } }
42
43
  async function launch(body, origin = null) {
43
44
  if (!String(body.prompt || '').trim()) throw bad('prompt required');
44
45
  // scratch: no project folder (general questions); the task gets an empty directory of its own.
@@ -72,6 +73,16 @@ export function createLaunch(app) {
72
73
  const attachments = app.uploads.resolve(body.attachments);
73
74
  const ids = runners.map(() => newId());
74
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
+ }
75
86
  if (wantTree) {
76
87
  const must = body.worktree === true || (comparing && mode !== 'plan');
77
88
  // One fetch for the whole launch, then the checkouts side by side: compare runs used to
@@ -101,9 +112,10 @@ export function createLaunch(app) {
101
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 }) }));
102
113
  }
103
114
 
104
- // Where a launch runs: body.machine, else a project folder that only exists on a machine
105
- // (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.
106
117
  function machineFor(body) {
118
+ if (typeof body.machine === 'string') return body.machine ? machines.get(body.machine).id : null; // '' = this computer
107
119
  if (body.machine) return machines.get(String(body.machine)).id;
108
120
  const cwd = String(body.cwd || '');
109
121
  if (!cwd || fs.existsSync(expand(cwd))) return null;
@@ -120,6 +132,8 @@ export function createLaunch(app) {
120
132
  // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
121
133
  async function launchAny(body, origin = null) {
122
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);
123
137
  if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
124
138
  // Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
125
139
  // back through the summaries it streams (auto.signals), so one router learns from every place.
@@ -141,11 +155,25 @@ export function createLaunch(app) {
141
155
  }
142
156
 
143
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).
144
159
  async function replyTo(s, text, attachments = [], queue, mode, run) {
160
+ const question = btwQuestion(text);
161
+ if (question !== null) return { btw: await askBtw(s, question, attachments) };
145
162
  for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
146
163
  return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
147
164
  }
148
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
+
149
177
  function markDone(s) {
150
178
  if (s.state === 'running' || s.state === 'waiting') return false;
151
179
  s.state = 'done';
@@ -164,5 +192,5 @@ export function createLaunch(app) {
164
192
  return s;
165
193
  }
166
194
 
167
- return { launch, launchAny, machineFor, pushUploads, replyTo, markDone, runRecipe };
195
+ return { launch, launchAny, machineFor, pushUploads, replyTo, askBtw, markDone, runRecipe };
168
196
  }
@@ -0,0 +1,130 @@
1
+ // Moving a task to another place (this computer or a machine) with a follow-up. A session lives on
2
+ // the wisp that runs it, so the move starts a new session there that carries the task on:
3
+ //
4
+ // pack the source packs each folder's work (git.js packWork: the branch, plus a snapshot
5
+ // of what isn't committed) into bundles, kept as uploads there.
6
+ // carry the bundles travel through this wisp to the target, like attachments do.
7
+ // launch the target gets the project's checkout (services/places.js), unpacks each bundle into
8
+ // a worktree on the same branch, and starts the follow-up with the earlier turns as
9
+ // its history: the harness there hears them first (handoff.js).
10
+ // retire the old session is archived, pointing at the new one.
11
+
12
+ import os from 'node:os';
13
+ import { listAgents, adapters } from '../adapters/index.js';
14
+ import { packWork, unpackWork } from '../git.js';
15
+ import { splitId } from '../machines.js';
16
+ import { workdir } from '../runner.js';
17
+ import { bad } from '../util.js';
18
+
19
+ export function createMove(app) {
20
+ const { store, machines, uploads } = app;
21
+ // What a place is called in a moved task's history: the machine's name, or this computer's.
22
+ const where = (mid) => (mid ? machines.get(mid).name : os.hostname().replace(/\.local$/, '') || 'this computer');
23
+
24
+ // Source side: the session and its folders' bundles. A running task has nothing settled to pack.
25
+ async function pack(s) {
26
+ if (s.agent === 'wisp') throw bad('This is the tour — launch a real wisp to move it.');
27
+ if (s.parentId) throw bad('A subagent runs where its lead does.');
28
+ if (s.state === 'running' || s.state === 'waiting') throw bad('Stop the task or let it finish first: a running task can’t move.', 409);
29
+ const one = async (dir, wt) => {
30
+ const u = uploads.reserve('work.bundle');
31
+ const out = await packWork(dir, wt, u.file).catch((err) => {
32
+ throw bad(`Couldn’t pack the work in ${dir}: ${err.message}`);
33
+ });
34
+ return out && { ...out, upload: u.add().id };
35
+ };
36
+ const scratch = store.isScratch(s.cwd);
37
+ const packs = scratch ? [] : [await one(workdir(s), s.worktree), ...(await Promise.all((s.also || []).map((a) => one(a.worktree?.cwd || a.cwd, a.worktree))))];
38
+ return { session: s, scratch, packs };
39
+ }
40
+
41
+ // Target side (launch.js): the worktrees for a moved launch, one per folder, from the bundles.
42
+ async function unpack(dirs, packs, { id, title }) {
43
+ return Promise.all(
44
+ dirs.map(async (cwd, i) => {
45
+ const p = packs?.[i];
46
+ const u = p && uploads.get(p.upload);
47
+ if (!p || !u) return null;
48
+ try {
49
+ return await unpackWork({ cwd, home: store.home, id, title, pack: p, file: u.path });
50
+ } finally {
51
+ uploads.remove(u.id);
52
+ }
53
+ }),
54
+ );
55
+ }
56
+
57
+ // A bundle from the source (null = here) as an upload on the target.
58
+ async function carry(upload, from, to) {
59
+ let u = from ? null : uploads.get(upload);
60
+ if (from) u = await uploads.receive(await machines.stream(from, 'GET', `/uploads/${upload}`, { timeout: 10 * 60_000 }), { name: 'work.bundle', type: 'application/octet-stream' });
61
+ if (!u) throw bad('The packed work went missing on its way.', 500);
62
+ if (!to) return u.id;
63
+ const there = await machines.upload(to, u);
64
+ uploads.remove(u.id); // the copy here was only on its way through
65
+ return there.id;
66
+ }
67
+
68
+ // The earlier turns, as history for the new session: what ran where, without what only made
69
+ // sense there (checkpoints, attachment files, subagent sessions).
70
+ const history = (s, from) =>
71
+ s.turns.map((t) => {
72
+ const { checkpoint, delegations, attachments, ...rest } = t;
73
+ return { ...rest, moved: t.moved || where(from), checkpoint: null, ...(t.status === 'running' && { status: 'stopped' }) };
74
+ });
75
+
76
+ // id: the session as this wisp names it (<id>@<machine> on a machine). to: a machine id, or
77
+ // null for this computer. Resolves the new session's summary.
78
+ async function move(id, to, { text, attachments = [], mode, agent, model, effort } = {}) {
79
+ const at = splitId(id);
80
+ const from = at?.machine || null;
81
+ to = to || null;
82
+ if (from === to) throw bad('The task is already there.');
83
+ if (to) machines.get(to);
84
+ if (to && !machines.isOnline(to)) throw bad(`${machines.get(to).name} is offline. Connect it from Machines.`, 503);
85
+ const local = !from && store.get(id);
86
+ if (!from && !local) throw bad('no such session', 404);
87
+ const src = from ? await machines.json(from, 'GET', `/sessions/${at.id}`) : local;
88
+ // The harness the follow-up runs on has to be there.
89
+ const run = { agent: agent || src.agent, model: model ?? (agent && agent !== src.agent ? '' : src.model || ''), effort: effort ?? (agent && agent !== src.agent ? '' : src.effort || '') };
90
+ const there = to ? machines.agentsOf(to) || [] : await listAgents();
91
+ if (!there.some((a) => a.id === run.agent && a.available)) throw bad(`${adapters[run.agent]?.name || run.agent} isn’t installed on ${where(to)}. Pick another harness for this follow-up.`);
92
+
93
+ const packed = from ? await machines.json(from, 'POST', `/sessions/${at.id}/pack`, {}, { timeout: 10 * 60_000 }) : await pack(local);
94
+ const s = from ? machines.session(from, packed.session) : packed.session;
95
+ const packs = await Promise.all(packed.packs.map(async (p) => p && { ...p, upload: await carry(p.upload, from, to) }));
96
+ if (from) for (const p of packed.packs) if (p) machines.json(from, 'DELETE', `/uploads/${p.upload}`).catch(() => {});
97
+
98
+ const body = {
99
+ prompt: text,
100
+ ...(packed.scratch ? { scratch: true } : { cwd: s.cwd, also: (s.also || []).map((a) => a.cwd) }),
101
+ machine: to || '',
102
+ agent: run.agent,
103
+ model: run.model,
104
+ effort: run.effort,
105
+ mode: mode || s.mode,
106
+ attachments,
107
+ ...(s.groupId && { groupId: s.groupId }),
108
+ ...(s.subagents?.length && { subagents: s.subagents }),
109
+ moved: { from: where(from), title: s.title, turns: history(s, from), packs, ...(s.origin && { origin: s.origin }) },
110
+ };
111
+ let next;
112
+ try {
113
+ [next] = await app.launcher.launchAny(body);
114
+ } catch (err) {
115
+ for (const p of packs) if (p) (to ? machines.json(to, 'DELETE', `/uploads/${p.upload}`) : Promise.resolve(uploads.remove(p.upload))).catch(() => {});
116
+ throw err;
117
+ }
118
+ // The old one steps aside: archived (its worktree stays until you delete it), pointing here.
119
+ const movedTo = { id: next.id, place: where(to) };
120
+ if (from) await machines.json(from, 'PATCH', `/sessions/${at.id}`, { archived: true, movedTo }).catch(() => {});
121
+ else {
122
+ Object.assign(local, { archived: true, unread: false, movedTo });
123
+ app.sessionChanged(local);
124
+ }
125
+ app.telemetry?.track('move_task', { from: from ? 'machine' : 'here', to: to ? 'machine' : 'here', agent: run.agent });
126
+ return { moved: true, from: id, session: next };
127
+ }
128
+
129
+ return { pack, unpack, move };
130
+ }
@@ -1,6 +1,8 @@
1
1
  // Phone (ntfy / Telegram): what a tap or a reply there does to a session, here or on a machine.
2
2
 
3
+ import { BTW_TIMEOUT_MS } from '../btw.js';
3
4
  import { splitId } from '../machines.js';
5
+ import { truncate } from '../util.js';
4
6
 
5
7
  export function phoneActions(app) {
6
8
  const { store, runner, machines } = app;
@@ -21,6 +23,17 @@ export function phoneActions(app) {
21
23
  return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
22
24
  }
23
25
 
26
+ // A /btw from the phone: its answer, once it's in (null if it was dismissed or never came).
27
+ async function btwAnswer(sessionId, btwId) {
28
+ for (const end = Date.now() + BTW_TIMEOUT_MS + 30_000; Date.now() < end; ) {
29
+ await new Promise((r) => setTimeout(r, 1500));
30
+ const b = (await sessionOps(sessionId).catch(() => null))?.s.turns.flatMap((t) => t.btw || []).find((x) => x.id === btwId);
31
+ if (!b) return null;
32
+ if (b.status !== 'running') return b;
33
+ }
34
+ return null;
35
+ }
36
+
24
37
  return {
25
38
  // /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
26
39
  launch: async ({ project, prompt }) => {
@@ -31,7 +44,8 @@ export function phoneActions(app) {
31
44
  app.broadcast('config', {});
32
45
  return s;
33
46
  },
34
- act: async ({ sessionId, kind, ref, index, text }) => {
47
+ // later(text): a message sent after this one (Telegram), for a /btw's answer.
48
+ act: async ({ sessionId, kind, ref, index, text, later }) => {
35
49
  const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
36
50
  if (ops?.error) return `Can’t reach it: ${ops.error}`;
37
51
  if (!ops) return 'That session is gone.';
@@ -59,6 +73,11 @@ export function phoneActions(app) {
59
73
  }
60
74
  if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
61
75
  const r = await ops.reply(String(text || '').trim());
76
+ if (r.btw) {
77
+ // Not awaited: the bot keeps taking messages while the side run answers.
78
+ btwAnswer(sessionId, r.btw.id).then((b) => b && later?.(truncate(b.status === 'done' ? `btw: ${b.answer}` : `Couldn’t answer that: ${b.error}`, 4000))).catch(() => {});
79
+ return later ? '✓ Asking on the side; the agent keeps working. The answer follows here.' : '✓ Asked on the side; the answer shows on the card in wisp.';
80
+ }
62
81
  return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
63
82
  },
64
83
  };