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.
- package/CHANGELOG.md +35 -1
- package/README.md +17 -8
- package/bin/wisp.js +14 -6
- package/package.json +4 -3
- package/server/adapters/claude.js +12 -7
- package/server/adapters/codex.js +13 -7
- 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 +22 -2
- 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/mcp-wisp.js +2 -1
- package/server/openapi.js +28 -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 +47 -10
- package/server/routes/settings.js +24 -38
- package/server/routes/ship.js +19 -24
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +178 -12
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +67 -7
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/ship.js +97 -10
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +3 -1
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +52 -1
- package/web/css/launcher.css +12 -0
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +14 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +5 -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 +10 -10
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +121 -67
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +25 -5
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +16 -13
- package/web/js/features/settings.js +24 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +101 -29
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
package/server/routes/ship.js
CHANGED
|
@@ -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 {
|
|
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
|
|
22
|
+
if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
|
|
20
23
|
app.ship.recheck(s);
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
80
|
-
const
|
|
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,
|
|
80
|
+
c.send(200, out);
|
|
86
81
|
}),
|
|
87
82
|
);
|
|
88
83
|
}
|
package/server/routes/uploads.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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')
|
|
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
|
-
|
|
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.
|
package/server/services/auto.js
CHANGED
|
@@ -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
|
|
113
|
-
const worktree =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|