wisp-agents 0.15.0 → 0.17.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 +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -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-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- 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 +176 -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 +351 -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 +216 -49
- 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 +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- 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 +84 -0
- package/web/css/feed.css +235 -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 +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- 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 +344 -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 +105 -0
- package/web/js/features/composer.js +315 -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 +561 -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/mentions.js +125 -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 +163 -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 +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -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
package/server/index.js
CHANGED
|
@@ -1,456 +1,162 @@
|
|
|
1
1
|
// HTTP API + SSE + static web UI. No dependencies.
|
|
2
|
+
//
|
|
3
|
+
// This file wires the pieces together; the logic lives next to it:
|
|
4
|
+
// http/ the route table, responses, static files
|
|
5
|
+
// routes/ one file per area of the API (sessions, ship, settings, machines, …)
|
|
6
|
+
// services/ what several routes and background jobs share (launching, Ship it, Auto, …)
|
|
2
7
|
|
|
3
8
|
import http from 'node:http';
|
|
4
9
|
import fs from 'node:fs';
|
|
5
10
|
import path from 'node:path';
|
|
6
|
-
import os from 'node:os';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
11
|
import { randomBytes } from 'node:crypto';
|
|
9
12
|
import { Store } from './store.js';
|
|
10
|
-
import { Runner, summarize
|
|
11
|
-
import {
|
|
13
|
+
import { Runner, summarize } from './runner.js';
|
|
14
|
+
import { listAgents } from './adapters/index.js';
|
|
12
15
|
import { welcomeSession } from './welcome.js';
|
|
13
|
-
import {
|
|
14
|
-
import { Telemetry, envDisabled, feedbackAvailable } from './telemetry.js';
|
|
15
|
-
import { blocksToMarkdown, sessionToMarkdown } from './render.js';
|
|
16
|
-
import { openapi } from './openapi.js';
|
|
16
|
+
import { Telemetry } from './telemetry.js';
|
|
17
17
|
import { Updater } from './update.js';
|
|
18
|
-
import { Preferences, Retro
|
|
19
|
-
import {
|
|
20
|
-
import { clip } from './adapters/common.js';
|
|
21
|
-
import { createWorktree, worktreeSource, ensureWorktree, removeWorktree, worktreeStatus, ship, merge, prView, repoRoot } from './git.js';
|
|
22
|
-
import { normalizeRecipe, findRecipe, recipeView, fill, positional } from './recipes.js';
|
|
23
|
-
import { Triggers, normalizeTrigger, triggerView } from './triggers.js';
|
|
18
|
+
import { Preferences, Retro } from './preferences.js';
|
|
19
|
+
import { Triggers } from './triggers.js';
|
|
24
20
|
import { Remote } from './remote.js';
|
|
25
|
-
import { Machines, splitId
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
export async function startServer({ port =
|
|
21
|
+
import { Machines, splitId } from './machines.js';
|
|
22
|
+
import { Router } from './router.js';
|
|
23
|
+
import { DEFAULT_PORT, VERSION } from './util.js';
|
|
24
|
+
import { Routes } from './http/router.js';
|
|
25
|
+
import { send, eventStream } from './http/respond.js';
|
|
26
|
+
import { serveStatic } from './http/static.js';
|
|
27
|
+
import { createAuto } from './services/auto.js';
|
|
28
|
+
import { createFeedback } from './services/feedback.js';
|
|
29
|
+
import { createInstaller } from './services/install.js';
|
|
30
|
+
import { createLaunch } from './services/launch.js';
|
|
31
|
+
import { phoneActions } from './services/phone.js';
|
|
32
|
+
import { createShip } from './services/ship.js';
|
|
33
|
+
import { trackTurns } from './services/telemetry.js';
|
|
34
|
+
import { Uploads } from './services/uploads.js';
|
|
35
|
+
import { notifyWebhooks } from './services/webhooks.js';
|
|
36
|
+
import { API_VERSION } from './routes/meta.js';
|
|
37
|
+
import * as metaRoutes from './routes/meta.js';
|
|
38
|
+
import * as autoRoutes from './routes/auto.js';
|
|
39
|
+
import * as internalRoutes from './routes/internal.js';
|
|
40
|
+
import * as uploadRoutes from './routes/uploads.js';
|
|
41
|
+
import * as settingsRoutes from './routes/settings.js';
|
|
42
|
+
import * as automationRoutes from './routes/automations.js';
|
|
43
|
+
import * as machineRoutes from './routes/machines.js';
|
|
44
|
+
import * as proxyRoutes from './routes/proxy.js';
|
|
45
|
+
import * as sessionRoutes from './routes/sessions.js';
|
|
46
|
+
import * as shipRoutes from './routes/ship.js';
|
|
47
|
+
|
|
48
|
+
export { API_VERSION };
|
|
49
|
+
|
|
50
|
+
// Order matters only where patterns overlap: the machine proxy claims <id>@<machine> first.
|
|
51
|
+
const ROUTES = [metaRoutes, autoRoutes, internalRoutes, uploadRoutes, settingsRoutes, automationRoutes, machineRoutes, proxyRoutes, sessionRoutes, shipRoutes];
|
|
52
|
+
|
|
53
|
+
// Idle keep-alive connections are closed by the client first (Node's fetch: 4s, browsers: longer
|
|
54
|
+
// but they retry). With Node's 5s default the two raced, and a request sent on a socket the
|
|
55
|
+
// server was closing failed with ECONNRESET.
|
|
56
|
+
const KEEP_ALIVE_MS = 65_000;
|
|
57
|
+
|
|
58
|
+
// `app` is what every route and service shares. Services reach each other through it at call
|
|
59
|
+
// time, so the order they are created in only matters for what runs right away.
|
|
60
|
+
export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', handleSignals = true } = {}) {
|
|
65
61
|
const store = new Store();
|
|
66
62
|
const token = randomBytes(16).toString('hex');
|
|
67
63
|
const runner = new Runner(store, { url: `http://127.0.0.1:${port}`, token });
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
const events = eventStream();
|
|
65
|
+
const app = {
|
|
66
|
+
port,
|
|
67
|
+
token,
|
|
68
|
+
store,
|
|
69
|
+
runner,
|
|
70
|
+
events,
|
|
71
|
+
broadcast: events.broadcast,
|
|
72
|
+
uploads: new Uploads(path.join(store.home, 'uploads')),
|
|
73
|
+
// After any change to the config: persist it, and tell the open pages to reload it.
|
|
74
|
+
configChanged() {
|
|
75
|
+
store.saveConfig();
|
|
76
|
+
events.broadcast('config', {});
|
|
77
|
+
},
|
|
78
|
+
// After a change to a session's own fields: persist it and send its new summary.
|
|
79
|
+
sessionChanged(s, opts) {
|
|
80
|
+
store.save(s, opts);
|
|
81
|
+
events.broadcast('summary', summarize(s, store));
|
|
82
|
+
},
|
|
83
|
+
};
|
|
79
84
|
|
|
80
85
|
if (store.all().length === 0) store.put(welcomeSession());
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
87
|
-
runner.on('summary', (s) => broadcast('summary', s));
|
|
88
|
-
runner.on('activity', (a) => broadcast('activity', a));
|
|
89
|
-
runner.on('turn', (t) => broadcast('turn', t));
|
|
90
|
-
runner.on('finished', (f) => broadcast('finished', f));
|
|
87
|
+
runner.on('summary', (s) => app.broadcast('summary', s));
|
|
88
|
+
runner.on('activity', (a) => app.broadcast('activity', a));
|
|
89
|
+
runner.on('turn', (t) => app.broadcast('turn', t));
|
|
90
|
+
runner.on('finished', (f) => app.broadcast('finished', f));
|
|
91
91
|
runner.on('finished', ({ session }) => notifyWebhooks(store, session));
|
|
92
|
-
runner.on('finished', ({ session }) => remote.notify(store.get(session.id), session).catch((err) => console.warn(`wisp: phone: ${err.message}`)));
|
|
92
|
+
runner.on('finished', ({ session }) => app.remote.notify(store.get(session.id), session).catch((err) => console.warn(`wisp: phone: ${err.message}`)));
|
|
93
93
|
|
|
94
94
|
// Machines: other computers running their own wisp, reached over SSH. Their sessions show up
|
|
95
95
|
// here as <id>@<machine>; their events are relayed into this stream.
|
|
96
|
-
|
|
96
|
+
app.machines = new Machines({
|
|
97
97
|
store,
|
|
98
98
|
version: VERSION,
|
|
99
99
|
apiVersion: API_VERSION,
|
|
100
|
-
onChange: () => broadcast('machines', machines.view()),
|
|
100
|
+
onChange: () => app.broadcast('machines', app.machines.view()),
|
|
101
101
|
onEvent: (event, data) => {
|
|
102
|
-
broadcast(event, data);
|
|
103
|
-
if ((event === 'finished' || event === 'summary') && data?.session)
|
|
104
|
-
else if (event === 'summary' && data?.id)
|
|
105
|
-
if (event === 'finished' && remote.on) notifyFromMachine(data.session).catch((err) => console.warn(`wisp: phone: ${err.message}`));
|
|
102
|
+
app.broadcast(event, data);
|
|
103
|
+
if ((event === 'finished' || event === 'summary') && data?.session) app.auto.signals(data.session);
|
|
104
|
+
else if (event === 'summary' && data?.id) app.auto.signals(data);
|
|
105
|
+
if (event === 'finished' && app.remote.on) notifyFromMachine(data.session).catch((err) => console.warn(`wisp: phone: ${err.message}`));
|
|
106
106
|
},
|
|
107
107
|
});
|
|
108
108
|
async function notifyFromMachine(summary) {
|
|
109
109
|
const at = splitId(summary.id);
|
|
110
|
-
const s = machines.session(at.machine, await machines.json(at.machine, 'GET', `/sessions/${at.id}`));
|
|
111
|
-
await remote.notify(s, summary);
|
|
110
|
+
const s = app.machines.session(at.machine, await app.machines.json(at.machine, 'GET', `/sessions/${at.id}`));
|
|
111
|
+
await app.remote.notify(s, summary);
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
const pushUploads = async (mid, ids) => {
|
|
115
|
-
const out = [];
|
|
116
|
-
for (const u of resolveAttachments(ids)) out.push((await machines.upload(mid, u)).id);
|
|
117
|
-
return out;
|
|
118
|
-
};
|
|
119
|
-
setInterval(() => broadcast('ping', {}), 25_000).unref();
|
|
113
|
+
setInterval(() => app.broadcast('ping', {}), 25_000).unref();
|
|
120
114
|
|
|
121
|
-
|
|
122
|
-
|
|
115
|
+
app.telemetry = new Telemetry(store);
|
|
116
|
+
app.updater = new Updater({ port, onChange: (u) => app.broadcast('update', u) });
|
|
123
117
|
|
|
124
118
|
// Learned preferences: injected into every turn, refreshed by a background retro per session.
|
|
125
|
-
|
|
126
|
-
|
|
119
|
+
app.prefs = new Preferences(store.home);
|
|
120
|
+
app.learning = () => store.config.defaults.learn !== 'off';
|
|
127
121
|
const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
|
|
128
|
-
runner.promptExtra = (s) => (learning() && s.agent !== 'wisp' ? prefs.prompt(projectOf(s.cwd)) : '');
|
|
129
|
-
|
|
122
|
+
runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '');
|
|
123
|
+
app.retro = new Retro({
|
|
130
124
|
store,
|
|
131
125
|
runner,
|
|
132
|
-
prefs,
|
|
126
|
+
prefs: app.prefs,
|
|
133
127
|
projectOf,
|
|
134
|
-
enabled: learning,
|
|
135
|
-
onStart: () => broadcast('preferences', {}),
|
|
128
|
+
enabled: app.learning,
|
|
129
|
+
onStart: () => app.broadcast('preferences', {}),
|
|
136
130
|
onDone: ({ session, ok, added = [], removed = [] }) => {
|
|
137
|
-
telemetry.track('retro', { agent: session.agent, ok, added: added.length, removed: removed.length });
|
|
138
|
-
broadcast('preferences', { added: added.length, removed: removed.length });
|
|
131
|
+
app.telemetry.track('retro', { agent: session.agent, ok, added: added.length, removed: removed.length });
|
|
132
|
+
app.broadcast('preferences', { added: added.length, removed: removed.length });
|
|
139
133
|
},
|
|
140
134
|
});
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
agentsHere = agents;
|
|
147
|
-
const added = router.sync(agents);
|
|
148
|
-
if (added.length) broadcast('router', { added });
|
|
149
|
-
return agents;
|
|
150
|
-
};
|
|
151
|
-
const autoOpts = () => {
|
|
152
|
-
const r = store.config.defaults.router || {};
|
|
153
|
-
return { explore: r.explore !== 'off', allowed: Array.isArray(r.allowed) && r.allowed.length ? r.allowed : null };
|
|
154
|
-
};
|
|
155
|
-
const goalOf = (model) => (GOALS[model] ? model : GOALS[store.config.defaults.models?.auto] ? store.config.defaults.models.auto : 'balanced');
|
|
156
|
-
// Subagents set to Auto are routed on their first task, from this computer's harnesses.
|
|
157
|
-
runner.routeAuto = (prompt) => router.choose({ prompt, agents: agentsHere, goal: goalOf(), ...autoOpts() });
|
|
158
|
-
// A routed session's outcome so far, from its summary: works the same for sessions on machines.
|
|
159
|
-
// Follow-ups after the first turn count as corrections; a merged or closed PR settles it.
|
|
160
|
-
const routeSignals = (sum) => {
|
|
161
|
-
if (!sum?.id || !router.route(sum.id) || sum.status === 'running' || sum.state === 'running') return;
|
|
162
|
-
const pr = sum.pr?.state;
|
|
163
|
-
router.signal(sum.id, {
|
|
164
|
-
hero: ['done', 'partial', 'blocked', 'info'].includes(sum.status) ? sum.status : undefined,
|
|
165
|
-
followups: Math.max(0, (sum.turnCount || 1) - 1),
|
|
166
|
-
merged: pr === 'MERGED' || undefined,
|
|
167
|
-
closed: pr === 'CLOSED' || undefined,
|
|
168
|
-
});
|
|
169
|
-
};
|
|
135
|
+
|
|
136
|
+
// Auto: the router, and what feeds it outcomes.
|
|
137
|
+
app.router = new Router(store.home);
|
|
138
|
+
app.auto = createAuto(app);
|
|
139
|
+
app.installer = createInstaller(app);
|
|
170
140
|
runner.on('started', ({ session }) => {
|
|
171
|
-
if (session.route && !router.route(session.id)) router.record(session.id, session.route);
|
|
141
|
+
if (session.route && !app.router.route(session.id)) app.router.record(session.id, session.route);
|
|
172
142
|
});
|
|
173
143
|
runner.on('ended', ({ session, turn, interrupted }) => {
|
|
174
144
|
if (!session.route || interrupted) return;
|
|
175
|
-
if (session.turns.indexOf(turn) === 0) router.signal(session.id, { error: turn.status === 'error' || undefined, costUsd: turn.stats?.costUsd, ms: turn.endedAt - turn.startedAt });
|
|
176
|
-
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
|
|
180
|
-
// Auto's choice, as fixed values only: never the prompt.
|
|
181
|
-
const routeProps = (s) =>
|
|
182
|
-
s.route ? { routed: true, routeGoal: s.route.goal, routeType: s.route.type, routeSize: s.route.size, routeExplored: !!s.route.explored, routeTrying: !!s.route.trying, routePropensity: s.route.propensity } : {};
|
|
183
|
-
runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
|
|
184
|
-
runner.on('ended', ({ session, turn, interrupted, errorText }) => {
|
|
185
|
-
const blocks = turn.presentation?.blocks || [];
|
|
186
|
-
const st = turn.stats || {};
|
|
187
|
-
telemetry.track('turn_ended', {
|
|
188
|
-
...run(session, turn),
|
|
189
|
-
...routeProps(session),
|
|
190
|
-
status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
|
|
191
|
-
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
192
|
-
durationMs: turn.endedAt - turn.startedAt,
|
|
193
|
-
blockCount: blocks.length,
|
|
194
|
-
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
195
|
-
presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
|
|
196
|
-
changedFiles: turn.changes.length,
|
|
197
|
-
inputTokens: st.inputTokens,
|
|
198
|
-
outputTokens: st.outputTokens,
|
|
199
|
-
costUsd: st.costUsd,
|
|
200
|
-
});
|
|
201
|
-
if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...run(session, turn) });
|
|
145
|
+
if (session.turns.indexOf(turn) === 0) app.router.signal(session.id, { error: turn.status === 'error' || undefined, costUsd: turn.stats?.costUsd, ms: turn.endedAt - turn.startedAt });
|
|
146
|
+
app.auto.signals(summarize(session, store));
|
|
202
147
|
});
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// One way in for every launch: the launcher, the CLI, recipes, triggers and compare runs.
|
|
206
|
-
// body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
|
|
207
|
-
// subagents: [{ name, agent, model, effort, use }] | preset: teamId }
|
|
208
|
-
async function launch(body, origin = null) {
|
|
209
|
-
if (!String(body.prompt || '').trim()) throw bad('prompt required');
|
|
210
|
-
const cwd = expand(body.cwd || process.cwd());
|
|
211
|
-
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
|
|
212
|
-
const d = store.config.defaults;
|
|
213
|
-
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
214
|
-
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
215
|
-
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
216
|
-
// compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
|
|
217
|
-
// resolved here: the router picks a harness and model (a different one per compare run).
|
|
218
|
-
const resolved = await resolveAuto(body, agentsHere.length ? agentsHere : await detect(), { team: subagents.length > 0 });
|
|
219
|
-
const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
|
|
220
|
-
const agent = r.agent || (i ? d.agent : '') || 'claude';
|
|
221
|
-
if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
|
|
222
|
-
return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
|
|
223
|
-
});
|
|
224
|
-
const mode = MODES.has(body.mode) ? body.mode : 'edits';
|
|
225
|
-
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
226
|
-
// runs need one each, or the harnesses would edit the same files.
|
|
227
|
-
const comparing = runners.length > 1;
|
|
228
|
-
const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
|
|
229
|
-
// groupId: one launch sent to several machines (or here and there) shows as one task.
|
|
230
|
-
const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
|
|
231
|
-
const attachments = resolveAttachments(body.attachments);
|
|
232
|
-
const ids = runners.map(() => newId());
|
|
233
|
-
let trees = ids.map(() => null);
|
|
234
|
-
if (wantTree) {
|
|
235
|
-
const must = body.worktree === true || (comparing && mode !== 'plan');
|
|
236
|
-
// One fetch for the whole launch, then the checkouts side by side: compare runs used to
|
|
237
|
-
// fetch and check out one after the other before anything showed up in the feed.
|
|
238
|
-
const results = await worktreeSource(cwd).then(
|
|
239
|
-
(source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
|
|
240
|
-
(err) => [{ status: 'rejected', reason: err }],
|
|
241
|
-
);
|
|
242
|
-
const failed = results?.find((x) => x.status === 'rejected')?.reason;
|
|
243
|
-
trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
|
|
244
|
-
if (must && (failed || trees.some((t) => !t))) {
|
|
245
|
-
await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
|
|
246
|
-
if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
|
|
247
|
-
throw bad(comparing ? 'Compare needs a git repo with at least one commit, so each harness gets its own worktree.' : 'Not a git repo with commits, so there is nothing to make a worktree from.');
|
|
248
|
-
}
|
|
249
|
-
if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
|
|
250
|
-
}
|
|
251
|
-
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// agent 'auto' (and compare entries set to it) → a harness and model picked by the router from
|
|
255
|
-
// `agents`: this computer's, or the machine's the launch goes to. Model is the goal for Auto.
|
|
256
|
-
async function resolveAuto(body, agents, { team = false } = {}) {
|
|
257
|
-
const want = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])];
|
|
258
|
-
if (!want.some((r) => r.agent === 'auto')) return body;
|
|
259
|
-
const taken = [];
|
|
260
|
-
const out = want.map((r, i) => {
|
|
261
|
-
if (r.agent !== 'auto') return r;
|
|
262
|
-
const pick = router.choose({ prompt: body.prompt, attachments: (body.attachments || []).length, agents, goal: goalOf(r.model), important: !!body.important, exclude: taken, needDelegate: i === 0 && team, ...autoOpts() });
|
|
263
|
-
if (!pick) throw bad('Auto found no installed harness to run this on.');
|
|
264
|
-
taken.push(pick.key);
|
|
265
|
-
return { agent: pick.agent, model: pick.model, effort: '', route: pick.route };
|
|
266
|
-
});
|
|
267
|
-
return { ...body, ...out[0], compare: out.slice(1) };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Where a launch runs: body.machine, else a project folder that only exists on a machine
|
|
271
|
-
// (recipes and schedules keep just the path). null = here.
|
|
272
|
-
function machineFor(body) {
|
|
273
|
-
if (body.machine) return machines.get(String(body.machine)).id;
|
|
274
|
-
const cwd = String(body.cwd || '');
|
|
275
|
-
if (!cwd || fs.existsSync(expand(cwd))) return null;
|
|
276
|
-
return store.config.projects.find((p) => p.machine && (cwd === p.path || cwd.startsWith(`${p.path}/`)))?.machine || null;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// launch(), here or on a machine. Resolves with summaries (a compare launch has several).
|
|
280
|
-
async function launchAny(body, origin = null) {
|
|
281
|
-
const mid = machineFor(body);
|
|
282
|
-
if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
|
|
283
|
-
// Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
|
|
284
|
-
// back through the summaries it streams (routeSignals), so one router learns from every place.
|
|
285
|
-
const routed = await resolveAuto(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
|
|
286
|
-
const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
|
|
287
|
-
const sendBody = { ...routed, compare: routed.compare?.map(({ route, ...c }) => c) };
|
|
288
|
-
delete sendBody.route;
|
|
289
|
-
if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
|
|
290
|
-
const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
|
|
291
|
-
(out.group || [out]).forEach((x, i) => routes[i] && router.record(qualify(x.id, mid), routes[i]));
|
|
292
|
-
const known = store.projectFor(out.cwd, mid);
|
|
293
|
-
(known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
|
|
294
|
-
store.saveConfig();
|
|
295
|
-
if (!known) broadcast('config', {});
|
|
296
|
-
return (out.group || [out]).map((x) => machines.summary(mid, x));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
300
|
-
async function replyTo(s, text, attachments = [], queue, mode) {
|
|
301
|
-
await ensureWorktree(s.worktree);
|
|
302
|
-
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const markDone = (s) => {
|
|
306
|
-
if (s.state === 'running' || s.state === 'waiting') return false;
|
|
307
|
-
s.state = 'done';
|
|
308
|
-
s.unread = false;
|
|
309
|
-
retro.schedule(s, 0);
|
|
310
|
-
store.save(s);
|
|
311
|
-
broadcast('summary', summarize(s, store));
|
|
312
|
-
return true;
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
// Recipes and triggers.
|
|
316
|
-
const runRecipe = async (r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) => {
|
|
317
|
-
const values = words ? { ...positional(r.prompt, words), ...params } : params;
|
|
318
|
-
const [s] = await launchAny({ prompt: fill(r.prompt, values), cwd: r.cwd || cwd, machine, agent: agent || r.agent || undefined, model: model ?? (r.model || undefined), effort: effort || r.effort || undefined, mode: mode || r.mode || undefined }, { kind: 'recipe', id: r.id, name: r.name });
|
|
319
|
-
r.runs = (r.runs || 0) + 1;
|
|
320
|
-
r.lastRunAt = Date.now();
|
|
321
|
-
store.saveConfig();
|
|
322
|
-
broadcast('config', {});
|
|
323
|
-
return s;
|
|
324
|
-
};
|
|
325
|
-
const triggers = new Triggers({ store, launch: async (body, origin) => (await launchAny(body, origin))[0], onChange: () => broadcast('config', {}) });
|
|
148
|
+
trackTurns(app);
|
|
326
149
|
|
|
150
|
+
app.launcher = createLaunch(app);
|
|
151
|
+
app.sendFeedback = createFeedback(app);
|
|
152
|
+
app.triggers = new Triggers({ store, launch: async (body, origin) => (await app.launcher.launchAny(body, origin))[0], onChange: () => app.broadcast('config', {}) });
|
|
327
153
|
// Phone: answers and follow-ups coming back from ntfy / Telegram.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
onChange: () => broadcast('remote', {}),
|
|
331
|
-
// /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
|
|
332
|
-
launch: async ({ project, prompt }) => {
|
|
333
|
-
const d = store.config.defaults;
|
|
334
|
-
const agent = d.agent || 'claude';
|
|
335
|
-
const model = d.models?.[agent] ?? d.model ?? '';
|
|
336
|
-
const [s] = await launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'edits' }, { kind: 'telegram', name: 'Telegram' });
|
|
337
|
-
broadcast('config', {});
|
|
338
|
-
return s;
|
|
339
|
-
},
|
|
340
|
-
act: async ({ sessionId, kind, ref, index, text }) => {
|
|
341
|
-
const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
|
|
342
|
-
if (ops?.error) return `Can’t reach it: ${ops.error}`;
|
|
343
|
-
if (!ops) return 'That session is gone.';
|
|
344
|
-
const { s } = ops;
|
|
345
|
-
if (kind === 'answer') {
|
|
346
|
-
if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
|
|
347
|
-
const answer = s.ask.options[index];
|
|
348
|
-
if (answer === undefined) return 'Unknown option.';
|
|
349
|
-
await ops.answer(answer);
|
|
350
|
-
return `✓ Answered: ${answer}`;
|
|
351
|
-
}
|
|
352
|
-
if (kind === 'reply') {
|
|
353
|
-
const turn = s.turns.at(-1);
|
|
354
|
-
if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
|
|
355
|
-
const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
|
|
356
|
-
const o = q?.options?.[index];
|
|
357
|
-
const msg = typeof o === 'string' ? o : o?.reply || o?.label;
|
|
358
|
-
if (!msg) return 'Unknown option.';
|
|
359
|
-
await ops.reply(msg);
|
|
360
|
-
return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
|
|
361
|
-
}
|
|
362
|
-
if (kind === 'done') {
|
|
363
|
-
if (s.turns.at(-1)?.id !== ref && ref) return 'There is newer work on that session; open wisp to review it.';
|
|
364
|
-
return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
|
|
365
|
-
}
|
|
366
|
-
if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
|
|
367
|
-
const r = await ops.reply(String(text || '').trim());
|
|
368
|
-
return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
|
|
369
|
-
},
|
|
370
|
-
});
|
|
154
|
+
app.remote = new Remote({ store, onChange: () => app.broadcast('remote', {}), ...phoneActions(app) });
|
|
155
|
+
app.ship = createShip(app);
|
|
371
156
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (!at) {
|
|
376
|
-
const s = store.get(sessionId);
|
|
377
|
-
return s && { s, answer: (a) => runner.answer(s, a), reply: (t) => replyTo(s, t), done: () => markDone(s) };
|
|
378
|
-
}
|
|
379
|
-
const call = (method, p, body) => machines.json(at.machine, method, `/sessions/${at.id}${p}`, body);
|
|
380
|
-
const s = await call('GET', '').catch((err) => {
|
|
381
|
-
if (err.status === 404) return null;
|
|
382
|
-
throw err;
|
|
383
|
-
});
|
|
384
|
-
const live = s && (s.state === 'running' || s.state === 'waiting');
|
|
385
|
-
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' })) };
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
|
|
389
|
-
// not yet pushed. Rechecked whenever wisp might have changed the worktree.
|
|
390
|
-
const checkShip = async (s) => {
|
|
391
|
-
if (!s?.worktree || s.parentId) return;
|
|
392
|
-
const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
|
|
393
|
-
const next = { dirty, ahead, unpushed };
|
|
394
|
-
if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
|
|
395
|
-
s.toShip = next;
|
|
396
|
-
store.save(s);
|
|
397
|
-
broadcast('summary', summarize(s, store));
|
|
398
|
-
};
|
|
399
|
-
// One check at a time per session (one asked for meanwhile runs right after); `lazy` (a card
|
|
400
|
-
// fetching its session) skips running sessions and runs at most every few seconds.
|
|
401
|
-
const shipChecks = new Map(); // id → true while checking, 'again' if another check is due
|
|
402
|
-
const shipCheckedAt = new Map();
|
|
403
|
-
const recheckShip = (s, { lazy = false } = {}) => {
|
|
404
|
-
if (!s?.worktree || s.parentId) return;
|
|
405
|
-
if (lazy && (runner.live.has(s.id) || shipChecks.has(s.id) || Date.now() - (shipCheckedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
|
|
406
|
-
if (shipChecks.has(s.id)) return void shipChecks.set(s.id, 'again');
|
|
407
|
-
shipChecks.set(s.id, true);
|
|
408
|
-
shipCheckedAt.set(s.id, Date.now());
|
|
409
|
-
checkShip(s)
|
|
410
|
-
.catch(() => {})
|
|
411
|
-
.finally(() => {
|
|
412
|
-
const again = shipChecks.get(s.id) === 'again';
|
|
413
|
-
shipChecks.delete(s.id);
|
|
414
|
-
if (again) recheckShip(s);
|
|
415
|
-
});
|
|
416
|
-
};
|
|
417
|
-
runner.on('ended', ({ session }) => recheckShip(store.get(session.id)));
|
|
418
|
-
for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheckShip(s);
|
|
419
|
-
|
|
420
|
-
// Ship it: follow open PRs (checks, reviews, merges) so their cards stay current.
|
|
421
|
-
const refreshPr = async (s) => {
|
|
422
|
-
if (!s.pr || !s.worktree) return s.pr;
|
|
423
|
-
const prev = s.pr;
|
|
424
|
-
const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
|
|
425
|
-
const pr = await prView(dir, prev.number);
|
|
426
|
-
const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
|
|
427
|
-
s.pr = pr;
|
|
428
|
-
if (pr.state !== prev.state) routeSignals(summarize(s, store));
|
|
429
|
-
// Things worth coming back for: CI finished either way, a review landed, it merged or closed.
|
|
430
|
-
const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
|
|
431
|
-
if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
|
|
432
|
-
store.save(s);
|
|
433
|
-
broadcast('summary', summarize(s, store));
|
|
434
|
-
return pr;
|
|
435
|
-
};
|
|
436
|
-
const prTimer = setInterval(async () => {
|
|
437
|
-
for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
|
|
438
|
-
}, PR_POLL_MS);
|
|
439
|
-
prTimer.unref();
|
|
440
|
-
|
|
441
|
-
// Default commit message and PR text, from the session's latest presentation.
|
|
442
|
-
const shipDraft = (s) => {
|
|
443
|
-
const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
|
|
444
|
-
const blocks = turn?.presentation?.blocks || [];
|
|
445
|
-
const hero = blocks.find((b) => b?.type === 'hero');
|
|
446
|
-
const text = `${hero?.title || s.title}`.trim();
|
|
447
|
-
const conventional = /^[a-z]+(\([\w./-]+\))?!?: /.test(text);
|
|
448
|
-
const type = /\b(fix|bug|broken|crash|flaky|regression)/i.test(`${text} ${s.turns[0]?.prompt || ''}`) ? 'fix' : /\b(docs?|readme|changelog)\b/i.test(text) ? 'docs' : /\b(refactor|clean ?up|simplif)/i.test(text) ? 'refactor' : /\btests?\b/i.test(text) ? 'test' : 'feat';
|
|
449
|
-
const title = conventional ? text : `${type}: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
450
|
-
const keep = blocks.filter((b) => !['hero', 'next', 'question', 'diff', 'code', 'image', 'terminal'].includes(b?.type));
|
|
451
|
-
const body = [hero?.summary, blocksToMarkdown(keep, turn)].filter(Boolean).join('\n\n').trim();
|
|
452
|
-
return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
|
|
453
|
-
};
|
|
157
|
+
const routes = new Routes();
|
|
158
|
+
for (const mod of ROUTES) mod.register(routes, app);
|
|
159
|
+
app.routes = routes;
|
|
454
160
|
|
|
455
161
|
const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
456
162
|
|
|
@@ -467,760 +173,21 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
467
173
|
|
|
468
174
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
469
175
|
try {
|
|
470
|
-
if (url.pathname.startsWith('/api/')) return
|
|
471
|
-
|
|
176
|
+
if (!url.pathname.startsWith('/api/')) return serveStatic(res, url.pathname);
|
|
177
|
+
if (!(await routes.handle(req, res, url))) send(res, 404, { error: 'not found' });
|
|
472
178
|
} catch (err) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
async function api(req, res, url) {
|
|
480
|
-
const parts = url.pathname.split('/').filter(Boolean).slice(1); // drop "api"
|
|
481
|
-
const [root, id, action] = parts;
|
|
482
|
-
const m = req.method;
|
|
483
|
-
|
|
484
|
-
if (root === 'events' && m === 'GET') {
|
|
485
|
-
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
486
|
-
res.write(`event: hello\ndata: {}\n\n`);
|
|
487
|
-
clients.add(res);
|
|
488
|
-
req.on('close', () => clients.delete(res));
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (root === 'health' && m === 'GET') return send(res, 200, { ok: true, name: 'wisp', version: VERSION, apiVersion: API_VERSION, pid: process.pid, url: `http://127.0.0.1:${port}` });
|
|
493
|
-
// New wisp-agents on npm? POST installs it and relaunches; running agents are stopped, so the
|
|
494
|
-
// client must pass force once it has confirmed that with the user.
|
|
495
|
-
if (root === 'update') {
|
|
496
|
-
if (m === 'GET') {
|
|
497
|
-
if (url.searchParams.has('check')) return send(res, 200, await updater.check());
|
|
498
|
-
updater.maybeCheck(); // the UI asks on focus and reconnect: a day-old check gets refreshed
|
|
499
|
-
return send(res, 200, updater.view());
|
|
500
|
-
}
|
|
501
|
-
if (m === 'POST') {
|
|
502
|
-
const body = await readJson(req);
|
|
503
|
-
if (runner.live.size && !body.force) return send(res, 409, { error: 'agents are running', running: runner.live.size });
|
|
504
|
-
await updater.install();
|
|
505
|
-
send(res, 202, updater.view());
|
|
506
|
-
telemetry.track('update', { from: updater.state.current, to: updater.state.latest, method: updater.method });
|
|
507
|
-
setTimeout(() => (shutdown(), process.exit(0)), 300);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
|
|
512
|
-
|
|
513
|
-
if (root === 'agents' && m === 'GET') return send(res, 200, await detect({ fresh: url.searchParams.has('fresh') }));
|
|
514
|
-
// Re-check: detect harnesses and models again, here and on every online machine.
|
|
515
|
-
if (root === 'agents' && id === 'recheck' && m === 'POST') {
|
|
516
|
-
const [agents] = await Promise.all([detect({ fresh: true }), machines.recheckAgents()]);
|
|
517
|
-
return send(res, 200, agents);
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses).
|
|
521
|
-
if (root === 'router') {
|
|
522
|
-
const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: autoOpts().explore, allowed: autoOpts().allowed }, ...router.report(agentsHere.length ? agentsHere : await detect()) });
|
|
523
|
-
if (!id && m === 'GET') return send(res, 200, await view());
|
|
524
|
-
if (!id && m === 'PATCH') {
|
|
525
|
-
const body = await readJson(req);
|
|
526
|
-
const d = store.config.defaults;
|
|
527
|
-
if (body.goal !== undefined) {
|
|
528
|
-
if (!GOALS[body.goal]) return send(res, 400, { error: `goal must be one of ${Object.keys(GOALS).join(', ')}` });
|
|
529
|
-
d.models = { ...d.models, auto: body.goal };
|
|
530
|
-
if (d.agent === 'auto') d.model = body.goal;
|
|
531
|
-
}
|
|
532
|
-
d.router = { ...d.router };
|
|
533
|
-
if (body.explore !== undefined) d.router.explore = body.explore ? 'on' : 'off';
|
|
534
|
-
if (body.allowed !== undefined) d.router.allowed = Array.isArray(body.allowed) ? body.allowed.filter((a) => adapters[a]) : null;
|
|
535
|
-
store.saveConfig();
|
|
536
|
-
broadcast('config', {});
|
|
537
|
-
return send(res, 200, await view());
|
|
538
|
-
}
|
|
539
|
-
if (id === 'reset' && m === 'POST') {
|
|
540
|
-
router.reset();
|
|
541
|
-
broadcast('router', {});
|
|
542
|
-
return send(res, 200, await view());
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Bridge for the ask_user MCP server running inside an agent process.
|
|
547
|
-
if (root === 'internal') {
|
|
548
|
-
if (req.headers['x-wisp-token'] !== token) return send(res, 403, { error: 'bad token' });
|
|
549
|
-
if (id === 'ask' && !action && m === 'POST') {
|
|
550
|
-
const body = await readJson(req);
|
|
551
|
-
const s = store.get(body.session);
|
|
552
|
-
if (!s) return send(res, 404, { error: 'no such session' });
|
|
553
|
-
telemetry.track('ask_user', { agent: s.agent });
|
|
554
|
-
return send(res, 200, { id: runner.ask(s, body) });
|
|
555
|
-
}
|
|
556
|
-
if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
|
|
557
|
-
// delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
|
|
558
|
-
if (id === 'delegate' && action) {
|
|
559
|
-
const lead = store.get(action);
|
|
560
|
-
if (!lead) return send(res, 404, { error: 'no such session' });
|
|
561
|
-
if (m === 'POST') {
|
|
562
|
-
const body = await readJson(req);
|
|
563
|
-
const delegations = runner.delegate(lead, body.tasks);
|
|
564
|
-
telemetry.track('delegate', { agent: lead.agent, tasks: delegations.length, harnesses: [...new Set(delegations.filter((d) => d.agent).map((d) => d.agent))] });
|
|
565
|
-
return send(res, 200, { delegations });
|
|
566
|
-
}
|
|
567
|
-
if (m === 'GET') {
|
|
568
|
-
const ids = (url.searchParams.get('ids') || '').split(',').filter(Boolean);
|
|
569
|
-
const list = runner.delegations(lead, ids);
|
|
570
|
-
if (!list.length) return send(res, 200, { done: true, text: 'No delegated tasks to report on.' });
|
|
571
|
-
const running = ({ d, child }) => child?.turns.find((t) => t.id === d.turnId)?.status === 'running';
|
|
572
|
-
return send(res, 200, { done: !list.some(running), text: list.map(({ d, child }) => delegationReport(d, child, adapters)).join('\n\n---\n\n') });
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Attachments: raw body streamed to disk, referenced by id from launches and replies.
|
|
578
|
-
if (root === 'uploads') {
|
|
579
|
-
if (!id && m === 'POST') {
|
|
580
|
-
const name = path.basename(url.searchParams.get('name') || 'file').replace(/[^\w.\- ]+/g, '_').slice(-120) || 'file';
|
|
581
|
-
const uid = newId();
|
|
582
|
-
const file = path.join(uploadsDir, `${uid}-${name}`);
|
|
583
|
-
let size = 0;
|
|
584
|
-
await new Promise((resolve, reject) => {
|
|
585
|
-
const out = fs.createWriteStream(file);
|
|
586
|
-
req.on('data', (c) => {
|
|
587
|
-
size += c.length;
|
|
588
|
-
if (size > MAX_UPLOAD) {
|
|
589
|
-
req.destroy();
|
|
590
|
-
out.destroy();
|
|
591
|
-
fs.rmSync(file, { force: true });
|
|
592
|
-
reject(Object.assign(new Error('file too large (500 MB max)'), { status: 413 }));
|
|
593
|
-
}
|
|
594
|
-
});
|
|
595
|
-
req.pipe(out);
|
|
596
|
-
out.on('finish', resolve);
|
|
597
|
-
out.on('error', reject);
|
|
598
|
-
});
|
|
599
|
-
const meta = { id: uid, name, size, mime: url.searchParams.get('type') || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file };
|
|
600
|
-
uploads.set(uid, meta);
|
|
601
|
-
fs.writeFileSync(path.join(uploadsDir, `${uid}.json`), JSON.stringify(meta));
|
|
602
|
-
return send(res, 201, meta);
|
|
603
|
-
}
|
|
604
|
-
if (id && m === 'GET' && splitId(id)) {
|
|
605
|
-
const at = splitId(id);
|
|
606
|
-
return pipeFrom(res, await machines.stream(at.machine, 'GET', `/uploads/${at.id}`));
|
|
607
|
-
}
|
|
608
|
-
if (id && m === 'GET') {
|
|
609
|
-
const u = uploads.get(id);
|
|
610
|
-
if (!u || !fs.existsSync(u.path)) return send(res, 404, { error: 'not found' });
|
|
611
|
-
res.writeHead(200, { 'content-type': u.mime, 'content-length': fs.statSync(u.path).size });
|
|
612
|
-
return fs.createReadStream(u.path).pipe(res);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (root === 'config') {
|
|
617
|
-
if (m === 'GET') return send(res, 200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() });
|
|
618
|
-
if (m === 'PATCH') {
|
|
619
|
-
const body = await readJson(req);
|
|
620
|
-
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
621
|
-
if (body.webhooks !== undefined) {
|
|
622
|
-
const hooks = Array.isArray(body.webhooks) ? body.webhooks : [];
|
|
623
|
-
const bad = hooks.find((h) => !/^https?:\/\/\S+$/.test(String(h?.url ?? h)));
|
|
624
|
-
if (bad !== undefined) return send(res, 400, { error: `Not an http(s) URL: ${bad?.url ?? bad}` });
|
|
625
|
-
store.config.webhooks = hooks.map((h) => (typeof h === 'string' ? { url: h } : { url: h.url, events: Array.isArray(h.events) ? h.events : undefined }));
|
|
626
|
-
}
|
|
627
|
-
store.saveConfig();
|
|
628
|
-
broadcast('config', {});
|
|
629
|
-
return send(res, 200, { ...store.config, remote: undefined });
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// What wisp learned: preferences and lessons, global and per project (scope = 'global' or a project path).
|
|
634
|
-
if (root === 'preferences') {
|
|
635
|
-
const view = () => ({
|
|
636
|
-
enabled: learning(),
|
|
637
|
-
global: prefs.data.global,
|
|
638
|
-
projects: Object.entries(prefs.data.projects).map(([p, v]) => ({ path: p, name: store.projectFor(p)?.name || path.basename(p), ...v })),
|
|
639
|
-
history: prefs.data.history.slice().reverse(),
|
|
640
|
-
running: retro.running,
|
|
641
|
-
});
|
|
642
|
-
if (m === 'GET') return send(res, 200, view());
|
|
643
|
-
if (m === 'PUT') {
|
|
644
|
-
const body = await readJson(req);
|
|
645
|
-
const scope = String(body.scope || '');
|
|
646
|
-
if (scope !== 'global' && !path.isAbsolute(scope)) return send(res, 400, { error: 'scope must be "global" or a project path' });
|
|
647
|
-
if (!KINDS.includes(body.kind)) return send(res, 400, { error: `kind must be one of ${KINDS.join(', ')}` });
|
|
648
|
-
if (!Array.isArray(body.items)) return send(res, 400, { error: 'items must be an array of strings' });
|
|
649
|
-
prefs.set(scope, body.kind, body.items);
|
|
650
|
-
broadcast('preferences', {});
|
|
651
|
-
return send(res, 200, view());
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Subagent presets: a saved team (members with harness, model, thinking and "use it for").
|
|
656
|
-
if (root === 'teams') {
|
|
657
|
-
const list = store.config.teams;
|
|
658
|
-
const saved = (status, body) => (store.saveConfig(), broadcast('config', {}), send(res, status, body));
|
|
659
|
-
if (!id && m === 'GET') return send(res, 200, list);
|
|
660
|
-
if (!id && m === 'POST') {
|
|
661
|
-
const t = normalizePreset(await readJson(req), adapters);
|
|
662
|
-
list.push(t);
|
|
663
|
-
return saved(201, t);
|
|
664
|
-
}
|
|
665
|
-
const t = list.find((x) => x.id === id);
|
|
666
|
-
if (!t) return send(res, 404, { error: 'no such preset' });
|
|
667
|
-
if (!action && m === 'GET') return send(res, 200, t);
|
|
668
|
-
if (!action && m === 'PATCH') return saved(200, Object.assign(t, normalizePreset(await readJson(req), adapters, t)));
|
|
669
|
-
if (!action && m === 'DELETE') {
|
|
670
|
-
store.config.teams = list.filter((x) => x !== t);
|
|
671
|
-
return saved(200, { ok: true });
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Recipes: saved prompts with {params}. POST /recipes/:id/run { params | words, cwd, … }.
|
|
676
|
-
if (root === 'recipes') {
|
|
677
|
-
const list = store.config.recipes;
|
|
678
|
-
if (!id && m === 'GET') return send(res, 200, list.map(recipeView));
|
|
679
|
-
if (!id && m === 'POST') {
|
|
680
|
-
const r = normalizeRecipe(await readJson(req));
|
|
681
|
-
list.push(r);
|
|
682
|
-
store.saveConfig();
|
|
683
|
-
broadcast('config', {});
|
|
684
|
-
return send(res, 201, recipeView(r));
|
|
179
|
+
// A refusal (bad input, not found, conflict) is an answer, not a server error.
|
|
180
|
+
const expected = err.status && err.status < 500;
|
|
181
|
+
if (!expected) {
|
|
182
|
+
console.error(err);
|
|
183
|
+
app.telemetry.captureError(err, { route: `${req.method} ${url.pathname.replace(/\/[a-f0-9]{8,}(?=\/|$)/g, '/:id')}` });
|
|
685
184
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if (!action && m === 'GET') return send(res, 200, recipeView(r));
|
|
689
|
-
if (!action && m === 'PATCH') {
|
|
690
|
-
Object.assign(r, normalizeRecipe(await readJson(req), r));
|
|
691
|
-
store.saveConfig();
|
|
692
|
-
broadcast('config', {});
|
|
693
|
-
return send(res, 200, recipeView(r));
|
|
694
|
-
}
|
|
695
|
-
if (!action && m === 'DELETE') {
|
|
696
|
-
store.config.recipes = list.filter((x) => x !== r);
|
|
697
|
-
store.saveConfig();
|
|
698
|
-
broadcast('config', {});
|
|
699
|
-
return send(res, 200, { ok: true });
|
|
700
|
-
}
|
|
701
|
-
if (action === 'run' && m === 'POST') {
|
|
702
|
-
const body = await readJson(req);
|
|
703
|
-
// machine: run it in a project there (r.cwd empty, or a path on that machine).
|
|
704
|
-
const s = await runRecipe(r, { params: body.params || {}, words: Array.isArray(body.words) ? body.words.map(String) : undefined, cwd: body.cwd, machine: body.machine || undefined, agent: body.agent, model: body.model, effort: body.effort, mode: body.mode });
|
|
705
|
-
return send(res, 201, s);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Triggers: schedules and GitHub watchers that launch sessions on their own.
|
|
710
|
-
if (root === 'triggers') {
|
|
711
|
-
const list = store.config.triggers;
|
|
712
|
-
if (!id && m === 'GET') return send(res, 200, list.map(triggerView));
|
|
713
|
-
if (!id && m === 'POST') {
|
|
714
|
-
const t = normalizeTrigger(await readJson(req), null, { recipes: store.config.recipes });
|
|
715
|
-
list.push(t);
|
|
716
|
-
store.saveConfig();
|
|
717
|
-
broadcast('config', {});
|
|
718
|
-
return send(res, 201, triggerView(t));
|
|
719
|
-
}
|
|
720
|
-
const t = list.find((x) => x.id === id);
|
|
721
|
-
if (!t) return send(res, 404, { error: 'no such trigger' });
|
|
722
|
-
if (!action && m === 'PATCH') {
|
|
723
|
-
Object.assign(t, normalizeTrigger(await readJson(req), t, { recipes: store.config.recipes }));
|
|
724
|
-
store.saveConfig();
|
|
725
|
-
broadcast('config', {});
|
|
726
|
-
return send(res, 200, triggerView(t));
|
|
727
|
-
}
|
|
728
|
-
if (!action && m === 'DELETE') {
|
|
729
|
-
store.config.triggers = list.filter((x) => x !== t);
|
|
730
|
-
store.saveConfig();
|
|
731
|
-
broadcast('config', {});
|
|
732
|
-
return send(res, 200, { ok: true });
|
|
733
|
-
}
|
|
734
|
-
if (action === 'run' && m === 'POST') {
|
|
735
|
-
const r = await triggers.runNow(t);
|
|
736
|
-
const started = Array.isArray(r) ? r : r ? [r] : [];
|
|
737
|
-
return send(res, 200, { started, trigger: triggerView(t) });
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// Phone: ntfy / Telegram settings (secrets masked), and a test message.
|
|
742
|
-
if (root === 'remote') {
|
|
743
|
-
if (!id && m === 'GET') return send(res, 200, remote.view());
|
|
744
|
-
if (!id && m === 'PUT') return send(res, 200, remote.update(await readJson(req)));
|
|
745
|
-
if (id === 'test' && m === 'POST') {
|
|
746
|
-
const sent = await remote.send({ title: '✨ wisp', text: 'Test from wisp: questions and finished tasks will show up like this.', actions: [] });
|
|
747
|
-
if (!sent) return send(res, 400, { error: 'Turn on ntfy or link Telegram first.' });
|
|
748
|
-
return send(res, 200, remote.view());
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Spend and outcomes. ?days=7|30|90
|
|
753
|
-
if (root === 'usage' && m === 'GET') {
|
|
754
|
-
return send(res, 200, usage(store.all(), { days: Number(url.searchParams.get('days')) || 30, project: (s) => store.projectFor(s.cwd) }));
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (root === 'projects') {
|
|
758
|
-
if (!id && m === 'GET') return send(res, 200, store.config.projects);
|
|
759
|
-
if (!id && m === 'POST') {
|
|
760
|
-
const body = await readJson(req);
|
|
761
|
-
if (body.machine) {
|
|
762
|
-
const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
|
|
763
|
-
broadcast('config', {});
|
|
764
|
-
return send(res, 201, p);
|
|
765
|
-
}
|
|
766
|
-
const dir = path.resolve(String(body.path || '').replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
767
|
-
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return send(res, 400, { error: `Not a folder: ${dir}` });
|
|
768
|
-
const p = store.addProject(dir, { name: body.name });
|
|
769
|
-
broadcast('config', {});
|
|
770
|
-
return send(res, 201, p);
|
|
771
|
-
}
|
|
772
|
-
// Find git repos in the usual places so adding projects is one click.
|
|
773
|
-
if (id === 'discover' && m === 'GET') {
|
|
774
|
-
const known = new Set(store.config.projects.map((p) => p.path));
|
|
775
|
-
const roots = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'].map((d) => path.join(os.homedir(), d));
|
|
776
|
-
const found = [];
|
|
777
|
-
const walk = (dir, depth) => {
|
|
778
|
-
if (found.length >= 60) return;
|
|
779
|
-
let entries;
|
|
780
|
-
try {
|
|
781
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
782
|
-
} catch {
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
if (entries.some((e) => e.name === '.git')) {
|
|
786
|
-
if (!known.has(dir)) found.push({ path: dir, name: path.basename(dir), mtime: fs.statSync(dir).mtimeMs });
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
if (depth <= 0) return;
|
|
790
|
-
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') walk(path.join(dir, e.name), depth - 1);
|
|
791
|
-
};
|
|
792
|
-
for (const r of roots) walk(r, 2);
|
|
793
|
-
return send(res, 200, found.sort((a, b) => b.mtime - a.mtime).slice(0, 24));
|
|
794
|
-
}
|
|
795
|
-
const p = store.config.projects.find((x) => x.id === id);
|
|
796
|
-
if (!p) return send(res, 404, { error: 'no such project' });
|
|
797
|
-
if (m === 'PATCH') {
|
|
798
|
-
const body = await readJson(req);
|
|
799
|
-
if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
|
|
800
|
-
if (typeof body.color === 'string') p.color = body.color;
|
|
801
|
-
store.saveConfig();
|
|
802
|
-
broadcast('config', {});
|
|
803
|
-
return send(res, 200, p);
|
|
804
|
-
}
|
|
805
|
-
if (m === 'DELETE') {
|
|
806
|
-
store.config.projects = store.config.projects.filter((x) => x.id !== id);
|
|
807
|
-
store.saveConfig();
|
|
808
|
-
broadcast('config', {});
|
|
809
|
-
return send(res, 200, { ok: true });
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Directory suggestions for the launcher's project picker.
|
|
814
|
-
if (root === 'fs' && m === 'GET') {
|
|
815
|
-
const raw = (url.searchParams.get('q') || '').replace(/^~(?=$|[\\/])/, os.homedir());
|
|
816
|
-
const trailing = /[\\/]$/.test(raw); // a trailing backslash counts too (Windows)
|
|
817
|
-
const dir = trailing ? raw : path.dirname(raw);
|
|
818
|
-
const prefix = trailing ? '' : path.basename(raw).toLowerCase();
|
|
819
|
-
let entries = [];
|
|
820
|
-
try {
|
|
821
|
-
entries = fs
|
|
822
|
-
.readdirSync(dir, { withFileTypes: true })
|
|
823
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name.toLowerCase().startsWith(prefix))
|
|
824
|
-
.slice(0, 12)
|
|
825
|
-
.map((e) => path.join(dir, e.name));
|
|
826
|
-
} catch {}
|
|
827
|
-
return send(res, 200, entries);
|
|
185
|
+
if (!res.headersSent) send(res, err.status || 500, { error: err.message });
|
|
186
|
+
else res.end();
|
|
828
187
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
// Feedback for the wisp team: thumbs on a turn and/or a note. { rating?, text?, sessionId?, turnId? }
|
|
833
|
-
if (root === 'feedback' && m === 'POST') return send(res, 200, await sendFeedback(await readJson(req)));
|
|
834
|
-
|
|
835
|
-
if (root === 'sessions') {
|
|
836
|
-
if (id && splitId(id)) return machineSessionApi(req, res, url, splitId(id), action);
|
|
837
|
-
if (!id) {
|
|
838
|
-
if (m === 'GET') {
|
|
839
|
-
const q = Object.fromEntries(url.searchParams);
|
|
840
|
-
const cwd = q.cwd && path.resolve(q.cwd.replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
841
|
-
// Subagent sessions belong to their lead; ?children=1 lists them too.
|
|
842
|
-
// Sessions on machines too (the last ones seen, for a machine that's offline).
|
|
843
|
-
const here = store.all().filter((x) => !x.parentId || q.children).map((x) => summarize(x, store));
|
|
844
|
-
let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
845
|
-
if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
|
|
846
|
-
if (q.agent) list = list.filter((x) => x.agent === q.agent);
|
|
847
|
-
if (q.project) list = list.filter((x) => x.projectId === q.project || x.project === q.project);
|
|
848
|
-
if (cwd) list = list.filter((x) => x.cwd === cwd || x.cwd?.startsWith(cwd + path.sep));
|
|
849
|
-
if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
|
|
850
|
-
if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
|
|
851
|
-
return send(res, 200, list);
|
|
852
|
-
}
|
|
853
|
-
if (m === 'POST') {
|
|
854
|
-
const body = await readJson(req);
|
|
855
|
-
// A machine's wisp hands on where a recipe, trigger or Telegram launch came from.
|
|
856
|
-
const o = body.origin;
|
|
857
|
-
const origin = o && ['recipe', 'trigger', 'telegram'].includes(o.kind) ? { kind: o.kind, id: String(o.id || ''), name: String(o.name || '') } : null;
|
|
858
|
-
const out = await launchAny(body, origin);
|
|
859
|
-
return send(res, 201, out.length > 1 ? { ...out[0], group: out } : out[0]);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
const s = store.get(id);
|
|
864
|
-
if (!s) return send(res, 404, { error: 'no such session' });
|
|
865
|
-
|
|
866
|
-
if (!action) {
|
|
867
|
-
// A lead comes with its subagents' sessions, so its cards can show their reports and diffs.
|
|
868
|
-
if (m === 'GET') recheckShip(s, { lazy: true }); // edits made outside wisp (in an editor) show up too
|
|
869
|
-
if (m === 'GET') return send(res, 200, s.children ? { ...s, childSessions: Object.fromEntries(Object.values(s.children).map((cid) => [cid, store.get(cid)]).filter(([, c]) => c)) } : s);
|
|
870
|
-
if (m === 'PATCH') {
|
|
871
|
-
const body = await readJson(req);
|
|
872
|
-
if (typeof body.title === 'string' && body.title.trim()) Object.assign(s, { title: body.title.trim(), titleAuto: false });
|
|
873
|
-
if (body.state === 'done' && s.state !== 'running') s.state = 'done';
|
|
874
|
-
if (body.state === 'review' && s.state === 'done') s.state = 'review';
|
|
875
|
-
if (typeof body.unread === 'boolean') s.unread = body.unread;
|
|
876
|
-
// The team can change between turns; the next turn's lead sees the new roster.
|
|
877
|
-
if (body.subagents !== undefined && !s.parentId) {
|
|
878
|
-
s.subagents = normalizeTeam(body.subagents, adapters);
|
|
879
|
-
s.children ||= {};
|
|
880
|
-
}
|
|
881
|
-
if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
|
|
882
|
-
if (body.archived === false) delete s.archived;
|
|
883
|
-
if (s.state === 'done' || s.archived) retro.schedule(s, 0); // finished with it: learn now
|
|
884
|
-
// Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
|
|
885
|
-
// discardWorktree: a compare run you didn't keep; its changes go too.
|
|
886
|
-
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' });
|
|
887
|
-
store.save(s);
|
|
888
|
-
broadcast('summary', summarize(s, store));
|
|
889
|
-
return send(res, 200, summarize(s, store));
|
|
890
|
-
}
|
|
891
|
-
if (m === 'DELETE') {
|
|
892
|
-
runner.stop(s);
|
|
893
|
-
await runner.live.get(s.id)?.done;
|
|
894
|
-
cleanupCheckpoints(workdir(s), s.id);
|
|
895
|
-
for (const cid of Object.values(s.children || {})) {
|
|
896
|
-
await runner.live.get(cid)?.done;
|
|
897
|
-
if (store.get(cid)) (store.delete(cid), broadcast('removed', { id: cid }));
|
|
898
|
-
}
|
|
899
|
-
// Its worktree goes with it; the branch only if it holds nothing beyond its base.
|
|
900
|
-
if (s.worktree) await removeWorktree(s.worktree, { force: true, branch: true }).catch(() => {});
|
|
901
|
-
store.delete(id);
|
|
902
|
-
broadcast('removed', { id });
|
|
903
|
-
return send(res, 200, { ok: true });
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Long-poll until the session needs the user (finished, or asking). ?timeout=seconds.
|
|
908
|
-
if (action === 'wait' && m === 'GET') {
|
|
909
|
-
const timeout = Math.min(Math.max(Number(url.searchParams.get('timeout')) || 60, 0), MAX_WAIT_S) * 1000;
|
|
910
|
-
const end = Date.now() + timeout;
|
|
911
|
-
let gone = false;
|
|
912
|
-
req.on('close', () => (gone = true));
|
|
913
|
-
while (!gone && store.get(id) && !settled(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 200));
|
|
914
|
-
if (!store.get(id)) return send(res, 404, { error: 'session was deleted' });
|
|
915
|
-
return send(res, 200, { ...summarize(s, store), timedOut: !settled(s) });
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// The latest (or ?turn=<id|index>) presentation, as blocks and as markdown.
|
|
919
|
-
if (action === 'presentation' && m === 'GET') {
|
|
920
|
-
const q = url.searchParams.get('turn');
|
|
921
|
-
const turn = q === null ? s.turns.at(-1) : s.turns.find((t) => t.id === q) || s.turns.at(Number(q));
|
|
922
|
-
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
923
|
-
const latest = turn === s.turns.at(-1);
|
|
924
|
-
const markdown = latest ? sessionToMarkdown(s) : blocksToMarkdown(turn.presentation?.blocks, turn);
|
|
925
|
-
if (url.searchParams.get('format') === 'markdown') {
|
|
926
|
-
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' });
|
|
927
|
-
return res.end(markdown);
|
|
928
|
-
}
|
|
929
|
-
return send(res, 200, {
|
|
930
|
-
sessionId: s.id,
|
|
931
|
-
turnId: turn.id,
|
|
932
|
-
turnIndex: s.turns.indexOf(turn),
|
|
933
|
-
state: s.state,
|
|
934
|
-
status: turn.status,
|
|
935
|
-
prompt: turn.prompt,
|
|
936
|
-
blocks: turn.presentation?.blocks || [],
|
|
937
|
-
notes: turn.presentation?.notes,
|
|
938
|
-
fallback: !!turn.presentation?.fallback,
|
|
939
|
-
checkpoint: turn.checkpoint ? { commit: turn.checkpoint.commit, head: turn.checkpoint.head, branch: turn.checkpoint.branch, dirty: turn.checkpoint.dirty, at: turn.checkpoint.at } : null,
|
|
940
|
-
rolledBackAt: turn.rolledBackAt || null,
|
|
941
|
-
changes: turn.changes.map((c) => ({ file: c.file, op: c.op, edits: c.edits.length, reverted: !!c.reverted })),
|
|
942
|
-
stats: turn.stats,
|
|
943
|
-
ask: latest ? s.ask || null : null,
|
|
944
|
-
markdown,
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
if (action === 'reply' && m === 'POST') {
|
|
949
|
-
const { text, attachments, queue, mode } = await readJson(req);
|
|
950
|
-
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
951
|
-
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
|
|
952
|
-
if (mode !== undefined && !MODES.has(mode)) return send(res, 400, { error: 'mode must be plan, edits or auto.' });
|
|
953
|
-
return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue, mode));
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
if (action === 'queue' && m === 'DELETE') {
|
|
957
|
-
const ok = runner.unqueue(s, url.searchParams.get('item') || '');
|
|
958
|
-
return ok ? send(res, 200, { ok }) : send(res, 404, { error: 'Already sent.' });
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
if (action === 'answer' && m === 'POST') {
|
|
962
|
-
const { text } = await readJson(req);
|
|
963
|
-
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
964
|
-
return send(res, 200, { ok: runner.answer(s, text.trim()) });
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// Revert (or re-apply) a single file's edits from a turn.
|
|
968
|
-
if (action === 'revert' && m === 'POST') {
|
|
969
|
-
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to revert files.' });
|
|
970
|
-
if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before reverting files.' });
|
|
971
|
-
const { turnId, file, restore } = await readJson(req);
|
|
972
|
-
const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
|
|
973
|
-
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
974
|
-
await ensureWorktree(s.worktree);
|
|
975
|
-
const result = revertFileChange(s, turn, file, { restore: !!restore });
|
|
976
|
-
turn.activity.push({ t: Date.now(), kind: 'edit', label: `${result.reverted ? 'Reverted' : 'Re-applied'} ${result.file}`, file: result.file });
|
|
977
|
-
s.updatedAt = Date.now();
|
|
978
|
-
store.save(s);
|
|
979
|
-
broadcast('summary', summarize(s, store));
|
|
980
|
-
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
981
|
-
telemetry.track('revert_file', { agent: s.agent, restore: !!restore });
|
|
982
|
-
recheckShip(s);
|
|
983
|
-
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Roll back (or re-apply) the project working tree to the checkpoint before a turn.
|
|
987
|
-
if (action === 'rollback' && m === 'POST') {
|
|
988
|
-
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to roll back turns.' });
|
|
989
|
-
if (runner.live.has(s.id)) return send(res, 409, { error: 'Stop the running turn before rolling back.' });
|
|
990
|
-
const { turnId, restore } = await readJson(req);
|
|
991
|
-
const turn = turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1);
|
|
992
|
-
if (!turn) return send(res, 404, { error: 'no such turn' });
|
|
993
|
-
await ensureWorktree(s.worktree);
|
|
994
|
-
const result = rollbackToTurn(s, turn, { restore: !!restore });
|
|
995
|
-
turn.activity.push({ t: Date.now(), kind: 'edit', label: result.rolledBack ? 'Restored checkpoint before this turn' : 'Re-applied turn changes' });
|
|
996
|
-
s.updatedAt = Date.now();
|
|
997
|
-
store.save(s);
|
|
998
|
-
broadcast('summary', summarize(s, store));
|
|
999
|
-
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
1000
|
-
telemetry.track('rollback_turn', { agent: s.agent, restore: !!restore });
|
|
1001
|
-
router.signal(s.id, { rolledBack: restore ? null : true });
|
|
1002
|
-
recheckShip(s);
|
|
1003
|
-
return send(res, 200, { ok: true, turnId: turn.id, ...result });
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Run a command from an actionable presentation block (actions, tests, terminal).
|
|
1007
|
-
if (action === 'exec' && m === 'POST') {
|
|
1008
|
-
const { command, turnId, key } = await readJson(req);
|
|
1009
|
-
const cmd = String(command || '').trim();
|
|
1010
|
-
if (!cmd) return send(res, 400, { error: 'command required' });
|
|
1011
|
-
const turn = (turnId && s.turns.find((t) => t.id === turnId)) || s.turns.at(-1);
|
|
1012
|
-
if (s.agent === 'wisp') {
|
|
1013
|
-
return send(res, 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 });
|
|
1014
|
-
}
|
|
1015
|
-
await ensureWorktree(s.worktree);
|
|
1016
|
-
const resExec = await execCommand(workdir(s), cmd);
|
|
1017
|
-
if (turn) {
|
|
1018
|
-
turn.actionRuns ||= {};
|
|
1019
|
-
turn.actionRuns[key || cmd] = { ...resExec, ranAt: Date.now() };
|
|
1020
|
-
turn.activity.push({ t: Date.now(), kind: 'run', label: `Ran ${clip(cmd, 60)}`, detail: cmd });
|
|
1021
|
-
store.save(s);
|
|
1022
|
-
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
1023
|
-
}
|
|
1024
|
-
telemetry.track('exec_action', { agent: s.agent, exitCode: resExec.exitCode });
|
|
1025
|
-
recheckShip(s);
|
|
1026
|
-
return send(res, 200, resExec);
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Take over: continue this exact agent session interactively in a terminal.
|
|
1030
|
-
if (action === 'takeover') {
|
|
1031
|
-
const adapter = adapters[s.agent];
|
|
1032
|
-
if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
|
|
1033
|
-
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
1034
|
-
await ensureWorktree(s.worktree);
|
|
1035
|
-
const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
|
|
1036
|
-
if (m === 'GET') return send(res, 200, { command });
|
|
1037
|
-
if (m === 'POST') {
|
|
1038
|
-
if (runner.live.has(s.id)) {
|
|
1039
|
-
runner.stop(s);
|
|
1040
|
-
await runner.live.get(s.id)?.done;
|
|
1041
|
-
}
|
|
1042
|
-
openInTerminal({ cwd: workdir(s), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
|
|
1043
|
-
s.takenOverAt = Date.now();
|
|
1044
|
-
telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
|
|
1045
|
-
store.save(s);
|
|
1046
|
-
return send(res, 200, { command });
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
if (action === 'stop' && m === 'POST') {
|
|
1051
|
-
runner.stop(s);
|
|
1052
|
-
return send(res, 200, { ok: true });
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
|
|
1056
|
-
// commit, push, open or update the PR.
|
|
1057
|
-
if (action === 'ship') {
|
|
1058
|
-
if (!s.worktree) return send(res, 400, { error: 'Ship it works on worktree sessions: launch with a worktree to get one.' });
|
|
1059
|
-
if (m === 'GET') {
|
|
1060
|
-
recheckShip(s);
|
|
1061
|
-
return send(res, 200, { branch: s.worktree.branch, base: s.worktree.base, remote: s.worktree.remote, ...(await worktreeStatus(s.worktree)), pr: s.pr || null, ...shipDraft(s) });
|
|
1062
|
-
}
|
|
1063
|
-
if (m === 'POST') {
|
|
1064
|
-
if (runner.live.has(s.id)) return send(res, 409, { error: 'Still working: wait for it to finish, or stop it.' });
|
|
1065
|
-
const body = await readJson(req);
|
|
1066
|
-
const draft = shipDraft(s);
|
|
1067
|
-
s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
|
|
1068
|
-
telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
|
|
1069
|
-
store.save(s, { now: true });
|
|
1070
|
-
broadcast('summary', summarize(s, store));
|
|
1071
|
-
recheckShip(s);
|
|
1072
|
-
return send(res, 200, s.pr);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
if (action === 'pr' && m === 'POST') {
|
|
1076
|
-
if (!s.pr) return send(res, 404, { error: 'No pull request yet.' });
|
|
1077
|
-
return send(res, 200, await refreshPr(s));
|
|
1078
|
-
}
|
|
1079
|
-
// Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
|
|
1080
|
-
if (action === 'merge' && m === 'POST') {
|
|
1081
|
-
if (!s.pr || !s.worktree) return send(res, 400, { error: 'No pull request to merge.' });
|
|
1082
|
-
const { pr, sync } = await merge(s.worktree, s.pr.number);
|
|
1083
|
-
s.pr = pr;
|
|
1084
|
-
markDone(s);
|
|
1085
|
-
telemetry.track('merge', { agent: s.agent });
|
|
1086
|
-
store.save(s, { now: true });
|
|
1087
|
-
broadcast('summary', summarize(s, store));
|
|
1088
|
-
return send(res, 200, { pr, sync });
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
// Images the agent pointed at (screenshots etc.). Served if inside the session's project,
|
|
1092
|
-
// or if the agent named that exact path in an image block (screenshots often land in /tmp).
|
|
1093
|
-
if (action === 'file' && m === 'GET') {
|
|
1094
|
-
const dir = workdir(s);
|
|
1095
|
-
let p = path.resolve(dir, url.searchParams.get('path') || '');
|
|
1096
|
-
const ext = path.extname(p).toLowerCase();
|
|
1097
|
-
const images = s.turns.flatMap((t) => (t.presentation?.blocks || []).filter((b) => b?.type === 'image'));
|
|
1098
|
-
const at = (v) => typeof v === 'string' && path.resolve(dir, v) === p;
|
|
1099
|
-
const named = images.find((b) => at(b.path || b.src));
|
|
1100
|
-
// A page rendered before keepImages() rewrote the block still asks for the original path.
|
|
1101
|
-
const kept = !named && images.find((b) => at(b.source) && typeof b.path === 'string');
|
|
1102
|
-
if (kept) p = path.resolve(dir, kept.path);
|
|
1103
|
-
if (!(p.startsWith(dir + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
|
|
1104
|
-
res.writeHead(200, { 'content-type': MIME[ext] });
|
|
1105
|
-
return fs.createReadStream(p).pipe(res);
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
send(res, 404, { error: 'not found' });
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// /api/machines: add, connect, and browse folders on other computers.
|
|
1113
|
-
async function machinesApi(req, res, url, id, action) {
|
|
1114
|
-
const m = req.method;
|
|
1115
|
-
const one = (mid) => machines.view().find((x) => x.id === mid);
|
|
1116
|
-
if (!id && m === 'GET') return send(res, 200, machines.view());
|
|
1117
|
-
if (!id && m === 'POST') {
|
|
1118
|
-
const body = await readJson(req);
|
|
1119
|
-
const x = machines.add(body);
|
|
1120
|
-
if (body.connect !== false) await machines.connect(x.id);
|
|
1121
|
-
return send(res, 201, one(x.id));
|
|
1122
|
-
}
|
|
1123
|
-
if (id === 'ssh-hosts' && m === 'GET') return send(res, 200, sshHosts());
|
|
1124
|
-
if (id === 'discover' && m === 'GET') return send(res, 200, await discoverHosts());
|
|
1125
|
-
machines.get(id); // 404s
|
|
1126
|
-
if (!action && m === 'PATCH') return send(res, 200, (await machines.update(id, await readJson(req)), one(id)));
|
|
1127
|
-
if (!action && m === 'DELETE') {
|
|
1128
|
-
machines.remove(id);
|
|
1129
|
-
broadcast('config', {});
|
|
1130
|
-
return send(res, 200, { ok: true });
|
|
1131
|
-
}
|
|
1132
|
-
if (action === 'connect' && m === 'POST') return send(res, 200, await machines.connect(id));
|
|
1133
|
-
if (action === 'disconnect' && m === 'POST') return send(res, 200, (machines.disconnect(id), one(id)));
|
|
1134
|
-
// Stop wisp over there (its agents too); restart starts this version in its place.
|
|
1135
|
-
if (action === 'stop' && m === 'POST') return send(res, 200, (await machines.stopRemote(id, { restart: !!(await readJson(req)).restart }), one(id)));
|
|
1136
|
-
if (action === 'fs' && m === 'GET') return send(res, 200, await machines.json(id, 'GET', `/fs?q=${encodeURIComponent(url.searchParams.get('q') || '')}`));
|
|
1137
|
-
if (action === 'discover' && m === 'GET') {
|
|
1138
|
-
const known = new Set(store.config.projects.filter((p) => p.machine === id).map((p) => p.path));
|
|
1139
|
-
return send(res, 200, (await machines.json(id, 'GET', '/projects/discover')).filter((f) => !known.has(f.path)));
|
|
1140
|
-
}
|
|
1141
|
-
send(res, 404, { error: 'not found' });
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// /api/sessions/<id>@<machine>/…: passed through to that machine's wisp, ids rewritten.
|
|
1145
|
-
// A rating sticks to its turn (it shows on the card and can be taken back). What goes out is
|
|
1146
|
-
// the rating, the user's own words, and how the run was set up — never the prompt or the result.
|
|
1147
|
-
async function sendFeedback({ rating, text, sessionId, turnId, source } = {}) {
|
|
1148
|
-
if (rating !== undefined && rating !== null && rating !== 'up' && rating !== 'down') throw Object.assign(new Error('rating must be up or down'), { status: 400 });
|
|
1149
|
-
text = String(text || '').trim();
|
|
1150
|
-
if (rating === undefined && !text) throw Object.assign(new Error('Write something, or pick 👍 / 👎.'), { status: 400 });
|
|
1151
|
-
// A session on a machine: that wisp keeps the rating on its turn and sends it.
|
|
1152
|
-
const at = sessionId && splitId(sessionId);
|
|
1153
|
-
if (sessionId && turnId && rating !== undefined) router.signal(sessionId, { feedback: rating });
|
|
1154
|
-
if (at) {
|
|
1155
|
-
try {
|
|
1156
|
-
return await machines.json(at.machine, 'POST', '/feedback', { rating, text, sessionId: at.id, turnId, source });
|
|
1157
|
-
} catch (err) {
|
|
1158
|
-
if (err.status === 400) throw err; // offline, or an older wisp there: send it from here, without the run
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
const s = sessionId && !at ? store.get(sessionId) : null;
|
|
1162
|
-
if (sessionId && !at && !s) throw Object.assign(new Error('no such session'), { status: 404 });
|
|
1163
|
-
const turn = s && (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
1164
|
-
if (turnId && s && !turn) throw Object.assign(new Error('no such turn'), { status: 404 });
|
|
1165
|
-
// Only thumbs on a turn stick to it; a rating in the feedback dialog is about wisp as a whole.
|
|
1166
|
-
if (turn && turnId && rating !== undefined) {
|
|
1167
|
-
turn.feedback = rating ? { rating, at: Date.now() } : undefined;
|
|
1168
|
-
store.save(s);
|
|
1169
|
-
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
1170
|
-
}
|
|
1171
|
-
const blocks = turn?.presentation?.blocks || [];
|
|
1172
|
-
const context = turn && {
|
|
1173
|
-
...run(s, turn),
|
|
1174
|
-
status: turn.status,
|
|
1175
|
-
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
1176
|
-
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
1177
|
-
durationMs: turn.endedAt ? turn.endedAt - turn.startedAt : undefined,
|
|
1178
|
-
tour: s.agent === 'wisp' || undefined,
|
|
1179
|
-
};
|
|
1180
|
-
const props = { ...context, rating: rating || undefined, source: String(source || (turn ? 'turn' : 'app')).slice(0, 20), machine: at ? true : undefined };
|
|
1181
|
-
// Thumbs on a turn count as a rating; anything written, or sent from the dialog, is 'feedback'.
|
|
1182
|
-
const event = text || !turnId ? 'feedback' : 'turn_rated';
|
|
1183
|
-
if (event === 'turn_rated' && rating === null) props.cleared = true;
|
|
1184
|
-
const sent = await telemetry.feedback(event, { ...props, text: text || undefined });
|
|
1185
|
-
return { ok: true, sent, available: feedbackAvailable() };
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
async function machineSessionApi(req, res, url, at, action) {
|
|
1189
|
-
const m = req.method;
|
|
1190
|
-
const mid = at.machine;
|
|
1191
|
-
const sub = `/sessions/${at.id}${action ? `/${action}` : ''}${url.search}`;
|
|
1192
|
-
if ((action === 'file' && m === 'GET') || (action === 'presentation' && url.searchParams.get('format') === 'markdown')) return pipeFrom(res, await machines.stream(mid, 'GET', sub));
|
|
1193
|
-
// Take over: a terminal here, signed in over there, resuming the agent in its folder.
|
|
1194
|
-
if (action === 'takeover') {
|
|
1195
|
-
const { command } = await machines.json(mid, 'GET', `/sessions/${at.id}/takeover`);
|
|
1196
|
-
const argv = machines.terminalArgv(mid, command);
|
|
1197
|
-
if (m === 'POST') {
|
|
1198
|
-
const s = await machines.json(mid, 'GET', `/sessions/${at.id}`);
|
|
1199
|
-
if (s.state === 'running' || s.state === 'waiting') await machines.json(mid, 'POST', `/sessions/${at.id}/stop`, {});
|
|
1200
|
-
openInTerminal({ cwd: os.homedir(), argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
|
|
1201
|
-
telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto', machine: true });
|
|
1202
|
-
}
|
|
1203
|
-
return send(res, 200, { command: argv.map(shq).join(' ') });
|
|
1204
|
-
}
|
|
1205
|
-
const body = m === 'GET' ? undefined : await readJson(req);
|
|
1206
|
-
if (action === 'reply' && body?.attachments?.length) body.attachments = await pushUploads(mid, body.attachments);
|
|
1207
|
-
const out = await machines.json(mid, m, sub, body, action === 'wait' ? { timeout: (MAX_WAIT_S + 30) * 1000 } : undefined);
|
|
1208
|
-
if (!action && m === 'GET') return send(res, 200, machines.session(mid, out));
|
|
1209
|
-
if ((!action && m === 'PATCH') || action === 'wait') return send(res, 200, machines.summary(mid, out));
|
|
1210
|
-
if (out && typeof out === 'object' && out.sessionId) out.sessionId = qualify(out.sessionId, mid);
|
|
1211
|
-
return send(res, 200, out);
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
function serveStatic(res, pathname) {
|
|
1215
|
-
const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
|
|
1216
|
-
if (!p.startsWith(WEB) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
|
|
1217
|
-
// SPA fallback
|
|
1218
|
-
res.writeHead(200, { 'content-type': MIME['.html'] });
|
|
1219
|
-
return fs.createReadStream(path.join(WEB, 'index.html')).pipe(res);
|
|
1220
|
-
}
|
|
1221
|
-
res.writeHead(200, { 'content-type': MIME[path.extname(p)] || 'application/octet-stream', 'cache-control': 'no-cache' });
|
|
1222
|
-
fs.createReadStream(p).pipe(res);
|
|
1223
|
-
}
|
|
188
|
+
});
|
|
189
|
+
server.keepAliveTimeout = KEEP_ALIVE_MS;
|
|
190
|
+
server.headersTimeout = KEEP_ALIVE_MS + 1000;
|
|
1224
191
|
|
|
1225
192
|
// Advertise this server so integrations (CLI, MCP, editors) can find it without knowing the port.
|
|
1226
193
|
const serverFile = path.join(store.home, 'server.json');
|
|
@@ -1228,15 +195,16 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
1228
195
|
try {
|
|
1229
196
|
if (JSON.parse(fs.readFileSync(serverFile, 'utf8')).pid === process.pid) fs.rmSync(serverFile, { force: true });
|
|
1230
197
|
} catch {}
|
|
1231
|
-
retro.stop();
|
|
1232
|
-
triggers.stop();
|
|
1233
|
-
remote.stop();
|
|
1234
|
-
machines.stop(); // the tunnels go; wisp over there keeps running, agents and all
|
|
1235
|
-
|
|
198
|
+
app.retro.stop();
|
|
199
|
+
app.triggers.stop();
|
|
200
|
+
app.remote.stop();
|
|
201
|
+
app.machines.stop(); // the tunnels go; wisp over there keeps running, agents and all
|
|
202
|
+
app.ship.stop();
|
|
1236
203
|
for (const s of store.all()) runner.stop(s);
|
|
1237
204
|
store.flush();
|
|
1238
|
-
telemetry.persist();
|
|
205
|
+
app.telemetry.persist();
|
|
1239
206
|
};
|
|
207
|
+
app.shutdown = shutdown;
|
|
1240
208
|
if (handleSignals) {
|
|
1241
209
|
const exit = () => (shutdown(), process.exit(0));
|
|
1242
210
|
process.once('SIGINT', exit);
|
|
@@ -1248,12 +216,13 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
1248
216
|
server.listen(port, host, resolve);
|
|
1249
217
|
});
|
|
1250
218
|
fs.writeFileSync(serverFile, JSON.stringify({ url: `http://127.0.0.1:${port}`, port, pid: process.pid, version: VERSION, apiVersion: API_VERSION, startedAt: Date.now() }, null, 2));
|
|
1251
|
-
updater.start();
|
|
1252
|
-
triggers.start();
|
|
1253
|
-
remote.start();
|
|
1254
|
-
machines.start();
|
|
219
|
+
app.updater.start();
|
|
220
|
+
app.triggers.start();
|
|
221
|
+
app.remote.start();
|
|
222
|
+
app.machines.start();
|
|
1255
223
|
|
|
1256
|
-
detect().catch(() => {});
|
|
224
|
+
app.auto.detect().catch(() => {});
|
|
225
|
+
const { telemetry } = app;
|
|
1257
226
|
if (telemetry.enabled) {
|
|
1258
227
|
if (store.config.defaults.telemetryNotice !== 'seen') console.log('wisp sends anonymous usage stats and error reports (no prompts, paths or code). Opt out: Settings, or WISP_TELEMETRY=0 / DO_NOT_TRACK=1.');
|
|
1259
228
|
listAgents()
|
|
@@ -1261,52 +230,5 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
1261
230
|
.catch(() => {});
|
|
1262
231
|
}
|
|
1263
232
|
|
|
1264
|
-
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers, remote, machines };
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
|
|
1268
|
-
// event: session.waiting (blocked on ask_user) | session.finished. Fire and forget.
|
|
1269
|
-
function notifyWebhooks(store, summary) {
|
|
1270
|
-
const hooks = store.config.webhooks || [];
|
|
1271
|
-
if (!hooks.length) return;
|
|
1272
|
-
const s = store.get(summary.id);
|
|
1273
|
-
if (!s) return;
|
|
1274
|
-
const event = s.state === 'waiting' ? 'session.waiting' : 'session.finished';
|
|
1275
|
-
const body = JSON.stringify({ event, session: summary, markdown: sessionToMarkdown(s), at: Date.now() });
|
|
1276
|
-
for (const h of hooks) {
|
|
1277
|
-
if (h.events?.length && !h.events.includes(event)) continue;
|
|
1278
|
-
fetch(h.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `wisp/${VERSION}` }, body, signal: AbortSignal.timeout(10_000) }).catch((err) =>
|
|
1279
|
-
console.warn(`wisp: webhook ${h.url} failed: ${err.message}`),
|
|
1280
|
-
);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// Pass a machine's raw response (images, markdown, uploads) straight through.
|
|
1285
|
-
function pipeFrom(res, r) {
|
|
1286
|
-
const headers = { 'content-type': r.headers['content-type'] || 'application/octet-stream' };
|
|
1287
|
-
if (r.headers['content-length']) headers['content-length'] = r.headers['content-length'];
|
|
1288
|
-
res.writeHead(r.statusCode, headers);
|
|
1289
|
-
r.pipe(res);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function send(res, status, body) {
|
|
1293
|
-
res.writeHead(status, { 'content-type': 'application/json' });
|
|
1294
|
-
res.end(JSON.stringify(body));
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
function readJson(req) {
|
|
1298
|
-
return new Promise((resolve, reject) => {
|
|
1299
|
-
let data = '';
|
|
1300
|
-
req.on('data', (c) => {
|
|
1301
|
-
data += c;
|
|
1302
|
-
if (data.length > 2e6) reject(Object.assign(new Error('body too large'), { status: 413 }));
|
|
1303
|
-
});
|
|
1304
|
-
req.on('end', () => {
|
|
1305
|
-
try {
|
|
1306
|
-
resolve(data ? JSON.parse(data) : {});
|
|
1307
|
-
} catch {
|
|
1308
|
-
reject(Object.assign(new Error('invalid json'), { status: 400 }));
|
|
1309
|
-
}
|
|
1310
|
-
});
|
|
1311
|
-
});
|
|
233
|
+
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry, triggers: app.triggers, remote: app.remote, machines: app.machines, routes };
|
|
1312
234
|
}
|