wisp-agents 0.15.0 → 0.16.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 +39 -1
- package/README.md +28 -12
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +56 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +339 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +87 -36
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +62 -0
- package/web/css/feed.css +208 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +16 -1
- package/web/js/app.js +28 -5237
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +339 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +104 -0
- package/web/js/features/composer.js +311 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +559 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +161 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Sessions: list and launch, one session (read, edit, delete), and what can be done to it:
|
|
2
|
+
// wait, presentation, reply, queue, answer, revert, rollback, exec, takeover, stop, file.
|
|
3
|
+
// Ids of the form <id>@<machine> are passed through to that machine's wisp (proxy.js).
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { adapters, listAgents } from '../adapters/index.js';
|
|
8
|
+
import { cleanupCheckpoints, revertFileChange, rollbackToTurn } from '../checkpoint.js';
|
|
9
|
+
import { execCommand } from '../proc.js';
|
|
10
|
+
import { ensureWorktree, removeWorktree } from '../git.js';
|
|
11
|
+
import { MIME } from '../http/static.js';
|
|
12
|
+
import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
|
|
13
|
+
import { summarize, workdir } from '../runner.js';
|
|
14
|
+
import { normalizeTeam } from '../subagents.js';
|
|
15
|
+
import { openInTerminal, shq } from '../terminal.js';
|
|
16
|
+
import { bad, clip, expand, MODES } from '../util.js';
|
|
17
|
+
|
|
18
|
+
export const MAX_WAIT_S = 290; // long-poll cap per request; clients loop (fetch drops headers after 300s)
|
|
19
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
20
|
+
|
|
21
|
+
// A session "needs you" once it stops running: finished, or blocked on ask_user. A finished run
|
|
22
|
+
// with follow-ups still queued is about to start the next one, so it doesn't count yet.
|
|
23
|
+
const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
|
|
24
|
+
|
|
25
|
+
export function register(r, app) {
|
|
26
|
+
const { store, runner, machines, telemetry, launcher } = app;
|
|
27
|
+
|
|
28
|
+
// A route on one session of this computer: 404 if there is no such session.
|
|
29
|
+
const one = (fn) => (c) => {
|
|
30
|
+
const s = store.get(c.params.id);
|
|
31
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
32
|
+
return fn(c, s);
|
|
33
|
+
};
|
|
34
|
+
// The welcome tour runs no agent: nothing to follow up, revert or roll back.
|
|
35
|
+
const notTour = (s, what) => {
|
|
36
|
+
if (s.agent === 'wisp') throw bad(`This is the tour — launch a real wisp to ${what}.`);
|
|
37
|
+
};
|
|
38
|
+
// A follow-up's harness switch, checked: a harness that's installed here, on a lead (not a subagent).
|
|
39
|
+
const followUpRun = async (s, { agent, model, effort }) => {
|
|
40
|
+
agent = agent ? String(agent) : s.agent;
|
|
41
|
+
if (agent !== s.agent) {
|
|
42
|
+
if (s.parentId) throw bad('A subagent runs on the harness its lead picked for it.');
|
|
43
|
+
if (!adapters[agent]) throw bad(`Unknown harness: ${agent}`);
|
|
44
|
+
const found = (await listAgents()).find((a) => a.id === agent);
|
|
45
|
+
if (!found?.available) throw bad(`${adapters[agent].name} isn't installed on this computer.`);
|
|
46
|
+
}
|
|
47
|
+
return { agent, ...(model !== undefined && { model: String(model || '') }), ...(effort !== undefined && { effort: String(effort || '') }) };
|
|
48
|
+
};
|
|
49
|
+
const turnOf = (s, turnId) => (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
50
|
+
|
|
51
|
+
r.route('GET', '/sessions', async (c) => {
|
|
52
|
+
const q = Object.fromEntries(c.query);
|
|
53
|
+
const cwd = q.cwd && expand(q.cwd);
|
|
54
|
+
// Subagent sessions belong to their lead; ?children=1 lists them too.
|
|
55
|
+
// Sessions on machines too (the last ones seen, for a machine that's offline).
|
|
56
|
+
const here = store.all().filter((x) => !x.parentId || q.children).map((x) => summarize(x, store));
|
|
57
|
+
let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
58
|
+
if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
|
|
59
|
+
if (q.agent) list = list.filter((x) => x.agent === q.agent);
|
|
60
|
+
if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
|
|
61
|
+
if (cwd) list = list.filter((x) => x.cwd === cwd || x.cwd?.startsWith(cwd + path.sep));
|
|
62
|
+
if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
|
|
63
|
+
if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
|
|
64
|
+
c.send(200, list);
|
|
65
|
+
});
|
|
66
|
+
r.route('POST', '/sessions', async (c) => {
|
|
67
|
+
const body = await c.body();
|
|
68
|
+
// A machine's wisp hands on where a recipe, trigger or Telegram launch came from.
|
|
69
|
+
const o = body.origin;
|
|
70
|
+
const origin = o && ['recipe', 'trigger', 'telegram'].includes(o.kind) ? { kind: o.kind, id: String(o.id || ''), name: String(o.name || '') } : null;
|
|
71
|
+
const out = await launcher.launchAny(body, origin);
|
|
72
|
+
c.send(201, out.length > 1 ? { ...out[0], group: out } : out[0]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
|
|
76
|
+
r.route(
|
|
77
|
+
'GET',
|
|
78
|
+
'/sessions/:id',
|
|
79
|
+
one((c, s) => {
|
|
80
|
+
app.ship.recheck(s, { lazy: true }); // edits made outside wisp (in an editor) show up too
|
|
81
|
+
c.send(200, s.children ? { ...s, childSessions: Object.fromEntries(Object.values(s.children).map((cid) => [cid, store.get(cid)]).filter(([, x]) => x)) } : s);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
r.route(
|
|
85
|
+
'PATCH',
|
|
86
|
+
'/sessions/:id',
|
|
87
|
+
one(async (c, s) => {
|
|
88
|
+
const body = await c.body();
|
|
89
|
+
if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
|
|
90
|
+
if (body.state === 'done' && s.state !== 'running') s.state = 'done';
|
|
91
|
+
if (body.state === 'review' && s.state === 'done') s.state = 'review';
|
|
92
|
+
if (typeof body.unread === 'boolean') s.unread = body.unread;
|
|
93
|
+
// The team can change between turns; the next turn's lead sees the new roster.
|
|
94
|
+
if (body.subagents !== undefined && !s.parentId) {
|
|
95
|
+
s.subagents = normalizeTeam(body.subagents, adapters);
|
|
96
|
+
s.children ||= {};
|
|
97
|
+
}
|
|
98
|
+
if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
|
|
99
|
+
if (body.archived === false) delete s.archived;
|
|
100
|
+
if (s.state === 'done' || s.archived) app.retro.schedule(s, 0); // finished with it: learn now
|
|
101
|
+
// Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
|
|
102
|
+
// discardWorktree: a compare run you didn't keep; its changes go too.
|
|
103
|
+
if (s.worktree && !runner.live.has(s.id) && (s.archived || body.discardWorktree)) await removeWorktree(s.worktree, { force: !!body.discardWorktree, branch: !!body.discardWorktree && !s.pr && 'force' });
|
|
104
|
+
app.sessionChanged(s);
|
|
105
|
+
c.send(200, summarize(s, store));
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
r.route(
|
|
109
|
+
'DELETE',
|
|
110
|
+
'/sessions/:id',
|
|
111
|
+
one(async (c, s) => {
|
|
112
|
+
runner.stop(s);
|
|
113
|
+
await runner.live.get(s.id)?.done;
|
|
114
|
+
cleanupCheckpoints(workdir(s), s.id);
|
|
115
|
+
for (const cid of Object.values(s.children || {})) {
|
|
116
|
+
await runner.live.get(cid)?.done;
|
|
117
|
+
if (store.get(cid)) (store.delete(cid), app.broadcast('removed', { id: cid }));
|
|
118
|
+
}
|
|
119
|
+
// Its worktree goes with it; the branch only if it holds nothing beyond its base.
|
|
120
|
+
if (s.worktree) await removeWorktree(s.worktree, { force: true, branch: true }).catch(() => {});
|
|
121
|
+
store.delete(s.id);
|
|
122
|
+
app.broadcast('removed', { id: s.id });
|
|
123
|
+
c.send(200, { ok: true });
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Long-poll until the session needs the user (finished, or asking). ?timeout=seconds.
|
|
128
|
+
r.route(
|
|
129
|
+
'GET',
|
|
130
|
+
'/sessions/:id/wait',
|
|
131
|
+
one(async (c, s) => {
|
|
132
|
+
const timeout = Math.min(Math.max(Number(c.query.get('timeout')) || 60, 0), MAX_WAIT_S) * 1000;
|
|
133
|
+
const end = Date.now() + timeout;
|
|
134
|
+
let gone = false;
|
|
135
|
+
c.req.on('close', () => (gone = true));
|
|
136
|
+
while (!gone && store.get(s.id) && !settled(s) && Date.now() < end) await new Promise((res) => setTimeout(res, 200));
|
|
137
|
+
if (!store.get(s.id)) return c.send(404, { error: 'session was deleted' });
|
|
138
|
+
c.send(200, { ...summarize(s, store), timedOut: !settled(s) });
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// The latest (or ?turn=<id|index>) presentation, as blocks and as markdown.
|
|
143
|
+
r.route(
|
|
144
|
+
'GET',
|
|
145
|
+
'/sessions/:id/presentation',
|
|
146
|
+
one((c, s) => {
|
|
147
|
+
const q = c.query.get('turn');
|
|
148
|
+
const turn = q === null ? s.turns.at(-1) : s.turns.find((t) => t.id === q) || s.turns.at(Number(q));
|
|
149
|
+
if (!turn) return c.send(404, { error: 'no such turn' });
|
|
150
|
+
const latest = turn === s.turns.at(-1);
|
|
151
|
+
const markdown = latest ? sessionToMarkdown(s) : blocksToMarkdown(turn.presentation?.blocks, turn);
|
|
152
|
+
if (c.query.get('format') === 'markdown') {
|
|
153
|
+
c.res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' });
|
|
154
|
+
return c.res.end(markdown);
|
|
155
|
+
}
|
|
156
|
+
c.send(200, {
|
|
157
|
+
sessionId: s.id,
|
|
158
|
+
turnId: turn.id,
|
|
159
|
+
turnIndex: s.turns.indexOf(turn),
|
|
160
|
+
state: s.state,
|
|
161
|
+
status: turn.status,
|
|
162
|
+
prompt: turn.prompt,
|
|
163
|
+
blocks: turn.presentation?.blocks || [],
|
|
164
|
+
notes: turn.presentation?.notes,
|
|
165
|
+
fallback: !!turn.presentation?.fallback,
|
|
166
|
+
checkpoint: turn.checkpoint ? { commit: turn.checkpoint.commit, head: turn.checkpoint.head, branch: turn.checkpoint.branch, dirty: turn.checkpoint.dirty, at: turn.checkpoint.at } : null,
|
|
167
|
+
rolledBackAt: turn.rolledBackAt || null,
|
|
168
|
+
changes: turn.changes.map((x) => ({ file: x.file, op: x.op, edits: x.edits.length, reverted: !!x.reverted })),
|
|
169
|
+
stats: turn.stats,
|
|
170
|
+
ask: latest ? s.ask || null : null,
|
|
171
|
+
markdown,
|
|
172
|
+
});
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
r.route(
|
|
177
|
+
'POST',
|
|
178
|
+
'/sessions/:id/reply',
|
|
179
|
+
one(async (c, s) => {
|
|
180
|
+
const { text, attachments, queue, mode, agent, model, effort } = await c.body();
|
|
181
|
+
if (!text?.trim()) return c.send(400, { error: 'text required' });
|
|
182
|
+
notTour(s, 'follow up');
|
|
183
|
+
if (mode !== undefined && !MODES.has(mode)) return c.send(400, { error: 'mode must be plan, edits or auto.' });
|
|
184
|
+
// agent / model / effort: run this follow-up (and the ones after it) on another harness or model.
|
|
185
|
+
const run = agent !== undefined || model !== undefined || effort !== undefined ? await followUpRun(s, { agent, model, effort }) : undefined;
|
|
186
|
+
c.send(200, await launcher.replyTo(s, text.trim(), app.uploads.resolve(attachments), queue, mode, run));
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
r.route(
|
|
191
|
+
'DELETE',
|
|
192
|
+
'/sessions/:id/queue',
|
|
193
|
+
one((c, s) => {
|
|
194
|
+
const ok = runner.unqueue(s, c.query.get('item') || '');
|
|
195
|
+
return ok ? c.send(200, { ok }) : c.send(404, { error: 'Already sent.' });
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
r.route(
|
|
200
|
+
'POST',
|
|
201
|
+
'/sessions/:id/answer',
|
|
202
|
+
one(async (c, s) => {
|
|
203
|
+
const { text } = await c.body();
|
|
204
|
+
if (!text?.trim()) return c.send(400, { error: 'text required' });
|
|
205
|
+
c.send(200, { ok: runner.answer(s, text.trim()) });
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Revert (or re-apply) a single file's edits from a turn, or roll back (or re-apply) the
|
|
210
|
+
// project's working tree to the checkpoint before a turn. Both note it on the turn and tell
|
|
211
|
+
// the cards; Ship it may have something new (or nothing left) to ship.
|
|
212
|
+
const undo = ({ what, busy, apply, label, event, after }) =>
|
|
213
|
+
one(async (c, s) => {
|
|
214
|
+
notTour(s, what);
|
|
215
|
+
if (runner.live.has(s.id)) return c.send(409, { error: busy });
|
|
216
|
+
const body = await c.body();
|
|
217
|
+
const turn = turnOf(s, body.turnId);
|
|
218
|
+
if (!turn) return c.send(404, { error: 'no such turn' });
|
|
219
|
+
await ensureWorktree(s.worktree);
|
|
220
|
+
const result = apply(s, turn, body);
|
|
221
|
+
turn.activity.push({ t: Date.now(), kind: 'edit', ...label(result) });
|
|
222
|
+
s.updatedAt = Date.now();
|
|
223
|
+
app.sessionChanged(s);
|
|
224
|
+
app.broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
225
|
+
telemetry.track(event, { agent: s.agent, restore: !!body.restore });
|
|
226
|
+
after?.(s, body);
|
|
227
|
+
app.ship.recheck(s);
|
|
228
|
+
c.send(200, { ok: true, turnId: turn.id, ...result });
|
|
229
|
+
});
|
|
230
|
+
r.route(
|
|
231
|
+
'POST',
|
|
232
|
+
'/sessions/:id/revert',
|
|
233
|
+
undo({
|
|
234
|
+
what: 'revert files',
|
|
235
|
+
busy: 'Stop the running turn before reverting files.',
|
|
236
|
+
apply: (s, turn, { file, restore }) => revertFileChange(s, turn, file, { restore: !!restore }),
|
|
237
|
+
label: (x) => ({ label: `${x.reverted ? 'Reverted' : 'Re-applied'} ${x.file}`, file: x.file }),
|
|
238
|
+
event: 'revert_file',
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
r.route(
|
|
242
|
+
'POST',
|
|
243
|
+
'/sessions/:id/rollback',
|
|
244
|
+
undo({
|
|
245
|
+
what: 'roll back turns',
|
|
246
|
+
busy: 'Stop the running turn before rolling back.',
|
|
247
|
+
apply: (s, turn, { restore }) => rollbackToTurn(s, turn, { restore: !!restore }),
|
|
248
|
+
label: (x) => ({ label: x.rolledBack ? 'Restored checkpoint before this turn' : 'Re-applied turn changes' }),
|
|
249
|
+
event: 'rollback_turn',
|
|
250
|
+
after: (s, { restore }) => app.router.signal(s.id, { rolledBack: restore ? null : true }),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Run a command from an actionable presentation block (actions, tests, terminal).
|
|
255
|
+
r.route(
|
|
256
|
+
'POST',
|
|
257
|
+
'/sessions/:id/exec',
|
|
258
|
+
one(async (c, s) => {
|
|
259
|
+
const { command, turnId, key } = await c.body();
|
|
260
|
+
const cmd = String(command || '').trim();
|
|
261
|
+
if (!cmd) return c.send(400, { error: 'command required' });
|
|
262
|
+
const turn = (turnId && s.turns.find((t) => t.id === turnId)) || s.turns.at(-1);
|
|
263
|
+
if (s.agent === 'wisp') {
|
|
264
|
+
return c.send(200, { command: cmd, output: '✓ Tour preview — in a real project, wisp runs this command in your repo folder and streams the exit code and output here.', exitCode: 0, durationMs: 18 });
|
|
265
|
+
}
|
|
266
|
+
await ensureWorktree(s.worktree);
|
|
267
|
+
const out = await execCommand(workdir(s), cmd);
|
|
268
|
+
if (turn) {
|
|
269
|
+
turn.actionRuns ||= {};
|
|
270
|
+
turn.actionRuns[key || cmd] = { ...out, ranAt: Date.now() };
|
|
271
|
+
turn.activity.push({ t: Date.now(), kind: 'run', label: `Ran ${clip(cmd, 60)}`, detail: cmd });
|
|
272
|
+
store.save(s);
|
|
273
|
+
app.broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
274
|
+
}
|
|
275
|
+
telemetry.track('exec_action', { agent: s.agent, exitCode: out.exitCode });
|
|
276
|
+
app.ship.recheck(s);
|
|
277
|
+
c.send(200, out);
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Take over: continue this exact agent session interactively in a terminal. GET: the command.
|
|
282
|
+
const takeover = (fn) =>
|
|
283
|
+
one(async (c, s) => {
|
|
284
|
+
const adapter = adapters[s.agent];
|
|
285
|
+
if (!adapter?.resumeCommand || !s.agentSessionId) return c.send(400, { error: 'This session cannot be resumed in a terminal.' });
|
|
286
|
+
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
287
|
+
await ensureWorktree(s.worktree);
|
|
288
|
+
const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
|
|
289
|
+
await fn(s, argv);
|
|
290
|
+
c.send(200, { command });
|
|
291
|
+
});
|
|
292
|
+
r.route('GET', '/sessions/:id/takeover', takeover(() => {}));
|
|
293
|
+
r.route(
|
|
294
|
+
'POST',
|
|
295
|
+
'/sessions/:id/takeover',
|
|
296
|
+
takeover(async (s, argv) => {
|
|
297
|
+
if (runner.live.has(s.id)) {
|
|
298
|
+
runner.stop(s);
|
|
299
|
+
await runner.live.get(s.id)?.done;
|
|
300
|
+
}
|
|
301
|
+
const d = store.config.defaults;
|
|
302
|
+
openInTerminal({ cwd: workdir(s), argv, terminal: d.terminal || 'auto', custom: d.terminalCommand });
|
|
303
|
+
s.takenOverAt = Date.now();
|
|
304
|
+
telemetry.track('takeover', { agent: s.agent, terminal: d.terminal || 'auto' });
|
|
305
|
+
store.save(s);
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
r.route(
|
|
310
|
+
'POST',
|
|
311
|
+
'/sessions/:id/stop',
|
|
312
|
+
one((c, s) => {
|
|
313
|
+
runner.stop(s);
|
|
314
|
+
c.send(200, { ok: true });
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Images the agent pointed at (screenshots etc.). Served if inside the session's project,
|
|
319
|
+
// or if the agent named that exact path in an image block (screenshots often land in /tmp).
|
|
320
|
+
r.route(
|
|
321
|
+
'GET',
|
|
322
|
+
'/sessions/:id/file',
|
|
323
|
+
one((c, s) => {
|
|
324
|
+
const dir = workdir(s);
|
|
325
|
+
let p = path.resolve(dir, c.query.get('path') || '');
|
|
326
|
+
const ext = path.extname(p).toLowerCase();
|
|
327
|
+
const images = s.turns.flatMap((t) => (t.presentation?.blocks || []).filter((b) => b?.type === 'image'));
|
|
328
|
+
const at = (v) => typeof v === 'string' && path.resolve(dir, v) === p;
|
|
329
|
+
const named = images.find((b) => at(b.path || b.src));
|
|
330
|
+
// A page rendered before keepImages() rewrote the block still asks for the original path.
|
|
331
|
+
const kept = !named && images.find((b) => at(b.source) && typeof b.path === 'string');
|
|
332
|
+
if (kept) p = path.resolve(dir, kept.path);
|
|
333
|
+
if (!(p.startsWith(dir + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return c.send(404, { error: 'not found' });
|
|
334
|
+
c.res.writeHead(200, { 'content-type': MIME[ext] });
|
|
335
|
+
fs.createReadStream(p).pipe(c.res);
|
|
336
|
+
}),
|
|
337
|
+
{ doc: false },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Settings: the config, what wisp learned, subagent presets, the phone, and projects.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { adapters } from '../adapters/index.js';
|
|
7
|
+
import { KINDS } from '../preferences.js';
|
|
8
|
+
import { normalizePreset } from '../subagents.js';
|
|
9
|
+
import { envDisabled, feedbackAvailable } from '../telemetry.js';
|
|
10
|
+
import { TERMINALS } from '../terminal.js';
|
|
11
|
+
import { normalizeWebhooks } from '../services/webhooks.js';
|
|
12
|
+
import { expand } from '../util.js';
|
|
13
|
+
|
|
14
|
+
// Where people keep their repos, for one-click "add project".
|
|
15
|
+
const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
|
|
16
|
+
|
|
17
|
+
export function register(r, app) {
|
|
18
|
+
const { store, telemetry, prefs, retro, remote, machines } = app;
|
|
19
|
+
|
|
20
|
+
r.route('GET', '/config', (c) =>
|
|
21
|
+
c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
|
|
22
|
+
);
|
|
23
|
+
r.route('PATCH', '/config', async (c) => {
|
|
24
|
+
const body = await c.body();
|
|
25
|
+
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
26
|
+
if (body.webhooks !== undefined) {
|
|
27
|
+
const { hooks, error } = normalizeWebhooks(body.webhooks);
|
|
28
|
+
if (error) return c.send(400, { error });
|
|
29
|
+
store.config.webhooks = hooks;
|
|
30
|
+
}
|
|
31
|
+
app.configChanged();
|
|
32
|
+
c.send(200, { ...store.config, remote: undefined });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// What wisp learned: preferences and lessons, global and per project (scope = 'global' or a project path).
|
|
36
|
+
const learned = () => ({
|
|
37
|
+
enabled: app.learning(),
|
|
38
|
+
global: prefs.data.global,
|
|
39
|
+
projects: Object.entries(prefs.data.projects).map(([p, v]) => ({ path: p, name: store.projectFor(p)?.name || path.basename(p), ...v })),
|
|
40
|
+
history: prefs.data.history.slice().reverse(),
|
|
41
|
+
running: retro.running,
|
|
42
|
+
});
|
|
43
|
+
r.route('GET', '/preferences', (c) => c.send(200, learned()));
|
|
44
|
+
r.route('PUT', '/preferences', async (c) => {
|
|
45
|
+
const body = await c.body();
|
|
46
|
+
const scope = String(body.scope || '');
|
|
47
|
+
if (scope !== 'global' && !path.isAbsolute(scope)) return c.send(400, { error: 'scope must be "global" or a project path' });
|
|
48
|
+
if (!KINDS.includes(body.kind)) return c.send(400, { error: `kind must be one of ${KINDS.join(', ')}` });
|
|
49
|
+
if (!Array.isArray(body.items)) return c.send(400, { error: 'items must be an array of strings' });
|
|
50
|
+
prefs.set(scope, body.kind, body.items);
|
|
51
|
+
app.broadcast('preferences', {});
|
|
52
|
+
c.send(200, learned());
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Subagent presets: a saved team (members with harness, model, thinking and "use it for").
|
|
56
|
+
const saved = (c, status, body) => (app.configChanged(), c.send(status, body));
|
|
57
|
+
const team = (c) => store.config.teams.find((x) => x.id === c.params.id);
|
|
58
|
+
r.route('GET', '/teams', (c) => c.send(200, store.config.teams));
|
|
59
|
+
r.route('POST', '/teams', async (c) => {
|
|
60
|
+
const t = normalizePreset(await c.body(), adapters);
|
|
61
|
+
store.config.teams.push(t);
|
|
62
|
+
saved(c, 201, t);
|
|
63
|
+
});
|
|
64
|
+
r.route('GET', '/teams/:id', (c) => {
|
|
65
|
+
const t = team(c);
|
|
66
|
+
if (!t) return c.send(404, { error: 'no such preset' });
|
|
67
|
+
c.send(200, t);
|
|
68
|
+
});
|
|
69
|
+
r.route('PATCH', '/teams/:id', async (c) => {
|
|
70
|
+
const t = team(c);
|
|
71
|
+
if (!t) return c.send(404, { error: 'no such preset' });
|
|
72
|
+
saved(c, 200, Object.assign(t, normalizePreset(await c.body(), adapters, t)));
|
|
73
|
+
});
|
|
74
|
+
r.route('DELETE', '/teams/:id', (c) => {
|
|
75
|
+
const t = team(c);
|
|
76
|
+
if (!t) return c.send(404, { error: 'no such preset' });
|
|
77
|
+
store.config.teams = store.config.teams.filter((x) => x !== t);
|
|
78
|
+
saved(c, 200, { ok: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Phone: ntfy / Telegram settings (secrets masked), and a test message.
|
|
82
|
+
r.route('GET', '/remote', (c) => c.send(200, remote.view()));
|
|
83
|
+
r.route('PUT', '/remote', async (c) => c.send(200, remote.update(await c.body())));
|
|
84
|
+
r.route('POST', '/remote/test', async (c) => {
|
|
85
|
+
const sent = await remote.send({ title: '✨ wisp', text: 'Test from wisp: questions and finished tasks will show up like this.', actions: [] });
|
|
86
|
+
if (!sent) return c.send(400, { error: 'Turn on ntfy or link Telegram first.' });
|
|
87
|
+
c.send(200, remote.view());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
r.route('GET', '/projects', (c) => c.send(200, store.config.projects));
|
|
91
|
+
r.route('POST', '/projects', async (c) => {
|
|
92
|
+
const body = await c.body();
|
|
93
|
+
if (body.machine) {
|
|
94
|
+
const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
|
|
95
|
+
app.broadcast('config', {});
|
|
96
|
+
return c.send(201, p);
|
|
97
|
+
}
|
|
98
|
+
const dir = expand(body.path || '');
|
|
99
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return c.send(400, { error: `Not a folder: ${dir}` });
|
|
100
|
+
const p = store.addProject(dir, { name: body.name });
|
|
101
|
+
app.broadcast('config', {});
|
|
102
|
+
c.send(201, p);
|
|
103
|
+
});
|
|
104
|
+
// Find git repos in the usual places so adding projects is one click.
|
|
105
|
+
r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => p.path)))), { doc: false });
|
|
106
|
+
const project = (c) => store.config.projects.find((x) => x.id === c.params.id);
|
|
107
|
+
r.route('PATCH', '/projects/:id', async (c) => {
|
|
108
|
+
const p = project(c);
|
|
109
|
+
if (!p) return c.send(404, { error: 'no such project' });
|
|
110
|
+
const body = await c.body();
|
|
111
|
+
if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
|
|
112
|
+
if (typeof body.color === 'string') p.color = body.color;
|
|
113
|
+
app.configChanged();
|
|
114
|
+
c.send(200, p);
|
|
115
|
+
});
|
|
116
|
+
r.route('DELETE', '/projects/:id', (c) => {
|
|
117
|
+
if (!project(c)) return c.send(404, { error: 'no such project' });
|
|
118
|
+
store.config.projects = store.config.projects.filter((x) => x.id !== c.params.id);
|
|
119
|
+
app.configChanged();
|
|
120
|
+
c.send(200, { ok: true });
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Git repos up to two levels under the usual folders, newest first, skipping known ones.
|
|
125
|
+
function discoverRepos(known) {
|
|
126
|
+
const found = [];
|
|
127
|
+
const walk = (dir, depth) => {
|
|
128
|
+
if (found.length >= 60) return;
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
132
|
+
} catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (entries.some((e) => e.name === '.git')) {
|
|
136
|
+
if (!known.has(dir)) found.push({ path: dir, name: path.basename(dir), mtime: fs.statSync(dir).mtimeMs });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (depth <= 0) return;
|
|
140
|
+
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') walk(path.join(dir, e.name), depth - 1);
|
|
141
|
+
};
|
|
142
|
+
for (const r of REPO_ROOTS) walk(path.join(os.homedir(), r), 2);
|
|
143
|
+
return found.sort((a, b) => b.mtime - a.mtime).slice(0, 24);
|
|
144
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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).
|
|
3
|
+
|
|
4
|
+
import { ship, merge, worktreeStatus } from '../git.js';
|
|
5
|
+
|
|
6
|
+
export function register(r, app) {
|
|
7
|
+
const { store, runner, telemetry } = app;
|
|
8
|
+
const one = (fn) => (c) => {
|
|
9
|
+
const s = store.get(c.params.id);
|
|
10
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
11
|
+
return fn(c, s);
|
|
12
|
+
};
|
|
13
|
+
const NO_TREE = 'Ship it works on worktree sessions: launch with a worktree to get one.';
|
|
14
|
+
|
|
15
|
+
r.route(
|
|
16
|
+
'GET',
|
|
17
|
+
'/sessions/:id/ship',
|
|
18
|
+
one(async (c, s) => {
|
|
19
|
+
if (!s.worktree) return c.send(400, { error: NO_TREE });
|
|
20
|
+
app.ship.recheck(s);
|
|
21
|
+
c.send(200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...app.ship.draft(s) });
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
r.route(
|
|
25
|
+
'POST',
|
|
26
|
+
'/sessions/:id/ship',
|
|
27
|
+
one(async (c, s) => {
|
|
28
|
+
if (!s.worktree) return c.send(400, { error: NO_TREE });
|
|
29
|
+
if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
|
|
30
|
+
const body = await c.body();
|
|
31
|
+
const draft = app.ship.draft(s);
|
|
32
|
+
s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
|
|
33
|
+
telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
|
|
34
|
+
app.sessionChanged(s, { now: true });
|
|
35
|
+
app.ship.recheck(s);
|
|
36
|
+
c.send(200, s.pr);
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
r.route(
|
|
40
|
+
'POST',
|
|
41
|
+
'/sessions/:id/pr',
|
|
42
|
+
one(async (c, s) => {
|
|
43
|
+
if (!s.pr) return c.send(404, { error: 'No pull request yet.' });
|
|
44
|
+
c.send(200, await app.ship.refreshPr(s));
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
// Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
|
|
48
|
+
r.route(
|
|
49
|
+
'POST',
|
|
50
|
+
'/sessions/:id/merge',
|
|
51
|
+
one(async (c, s) => {
|
|
52
|
+
if (!s.pr || !s.worktree) return c.send(400, { error: 'No pull request to merge.' });
|
|
53
|
+
const { pr, sync } = await merge(s.worktree, s.pr.number);
|
|
54
|
+
s.pr = pr;
|
|
55
|
+
app.launcher.markDone(s);
|
|
56
|
+
telemetry.track('merge', { agent: s.agent });
|
|
57
|
+
app.sessionChanged(s, { now: true });
|
|
58
|
+
c.send(200, { pr, sync });
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Attachments: POST streams the raw body to disk; GET serves it (from a machine for <id>@<machine>).
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { pipeFrom } from '../http/respond.js';
|
|
5
|
+
import { splitId } from '../machines.js';
|
|
6
|
+
|
|
7
|
+
export function register(r, app) {
|
|
8
|
+
const { uploads, machines } = app;
|
|
9
|
+
|
|
10
|
+
r.route('POST', '/uploads', async (c) => c.send(201, await uploads.receive(c.req, { name: c.query.get('name'), type: c.query.get('type') })));
|
|
11
|
+
r.route('GET', '/uploads/:id', async (c) => {
|
|
12
|
+
const at = splitId(c.params.id);
|
|
13
|
+
if (at) return pipeFrom(c.res, await machines.stream(at.machine, 'GET', `/uploads/${at.id}`));
|
|
14
|
+
const u = uploads.get(c.params.id);
|
|
15
|
+
if (!u || !fs.existsSync(u.path)) return c.send(404, { error: 'not found' });
|
|
16
|
+
c.res.writeHead(200, { 'content-type': u.mime, 'content-length': fs.statSync(u.path).size });
|
|
17
|
+
fs.createReadStream(u.path).pipe(c.res);
|
|
18
|
+
});
|
|
19
|
+
}
|