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.
- package/CHANGELOG.md +24 -1
- package/README.md +13 -8
- package/bin/wisp.js +10 -3
- package/package.json +4 -3
- package/server/adapters/claude.js +8 -4
- package/server/adapters/codex.js +9 -6
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +19 -0
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/openapi.js +27 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +32 -2
- package/server/routes/settings.js +24 -38
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +154 -8
- package/server/services/launch.js +32 -4
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +1 -0
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +49 -1
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/ship.css +2 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +3 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +1 -1
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +104 -94
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +15 -5
- package/web/js/features/projects.js +14 -12
- package/web/js/features/settings.js +24 -3
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +99 -28
- package/web/js/slash-text.js +22 -0
- 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 {
|
|
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
|
-
|
|
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:
|
|
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')
|
|
620
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/server/services/phone.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|