wisp-agents 0.14.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5232
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1901
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, newId, workdir } from './runner.js';
11
- import { adapters, listAgents } from './adapters/index.js';
13
+ import { Runner, summarize } from './runner.js';
14
+ import { listAgents } from './adapters/index.js';
12
15
  import { welcomeSession } from './welcome.js';
13
- import { openInTerminal, TERMINALS, shq } from './terminal.js';
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, KINDS } from './preferences.js';
19
- import { cleanupCheckpoints, revertFileChange, rollbackToTurn, execCommand } from './checkpoint.js';
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, qualify, sshHosts, discoverHosts } from './machines.js';
26
- import { usage } from './usage.js';
27
- import { normalizeTeam, normalizePreset, delegationReport } from './subagents.js';
28
- import { Router, GOALS } from './router.js';
29
-
30
- const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
31
-
32
- const MIME = {
33
- '.html': 'text/html; charset=utf-8',
34
- '.js': 'text/javascript; charset=utf-8',
35
- '.css': 'text/css; charset=utf-8',
36
- '.svg': 'image/svg+xml',
37
- '.png': 'image/png',
38
- '.jpg': 'image/jpeg',
39
- '.jpeg': 'image/jpeg',
40
- '.gif': 'image/gif',
41
- '.webp': 'image/webp',
42
- '.ico': 'image/x-icon',
43
- '.mp4': 'video/mp4',
44
- '.webm': 'video/webm',
45
- '.mov': 'video/quicktime',
46
- '.pdf': 'application/pdf',
47
- };
48
- const MAX_UPLOAD = 500 * 1024 * 1024;
49
- const MAX_WAIT_S = 290; // long-poll cap per request; clients loop (fetch drops headers after 300s)
50
- const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
51
- export const API_VERSION = 1;
52
-
53
- // A session "needs you" once it stops running: finished, or blocked on ask_user. A finished run
54
- // with follow-ups still queued is about to start the next one, so it doesn't count yet.
55
- const settled = (s) => s.state === 'waiting' || (s.state !== 'running' && !s.queue?.length);
56
- const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
57
- const PR_POLL_MS = 60_000;
58
- const SHIP_RECHECK_MS = 5_000;
59
- const MAX_COMPARE = 4;
60
- const MODES = new Set(['plan', 'edits', 'auto']);
61
- const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
62
- const expand = (p) => path.resolve(String(p).replace(/^~(?=$|[\\/])/, os.homedir()));
63
-
64
- export async function startServer({ port = 4477, host = '127.0.0.1', handleSignals = true } = {}) {
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 uploadsDir = path.join(store.home, 'uploads');
69
- fs.mkdirSync(uploadsDir, { recursive: true });
70
- const uploads = new Map(); // id -> { id, name, mime, size, path }
71
- for (const f of fs.readdirSync(uploadsDir)) {
72
- if (!f.endsWith('.json')) continue;
73
- try {
74
- const u = JSON.parse(fs.readFileSync(path.join(uploadsDir, f), 'utf8'));
75
- uploads.set(u.id, u);
76
- } catch {}
77
- }
78
- const resolveAttachments = (ids) => (Array.isArray(ids) ? ids.map((i) => uploads.get(i)).filter(Boolean) : []);
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
- const clients = new Set();
83
- const broadcast = (event, data) => {
84
- const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
85
- for (const res of clients) res.write(frame);
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
- const machines = new Machines({
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) routeSignals(data.session);
104
- else if (event === 'summary' && data?.id) routeSignals(data);
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
- // Attachments are uploaded here; a machine gets its own copies before a launch or reply.
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
- const telemetry = new Telemetry(store);
122
- const updater = new Updater({ port, onChange: (u) => broadcast('update', u) });
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
- const prefs = new Preferences(store.home);
126
- const learning = () => store.config.defaults.learn !== 'off';
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
- const retro = new Retro({
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
- // Auto: wisp picks harness and model per task and learns from outcomes (server/router.js).
142
- const router = new Router(store.home);
143
- let agentsHere = [];
144
- const detect = async (opts) => {
145
- const agents = await listAgents(opts);
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
- routeSignals(summarize(session, store));
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
- if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
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
- const remote = new Remote({
329
- store,
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
- // What the phone can do to a session, here or on a machine: { s, answer, reply, done } or null.
373
- async function sessionOps(sessionId) {
374
- const at = splitId(sessionId);
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 await api(req, res, url);
471
- return serveStatic(res, url.pathname);
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
- console.error(err);
474
- if (!err.status || err.status >= 500) telemetry.captureError(err, { route: `${req.method} ${url.pathname.replace(/\/[a-f0-9]{8,}(?=\/|$)/g, '/:id')}` });
475
- send(res, err.status || 500, { error: err.message });
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
- const r = findRecipe(list, decodeURIComponent(id || ''));
687
- if (!r) return send(res, 404, { error: 'no such recipe' });
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
- if (root === 'machines') return machinesApi(req, res, url, id, action);
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
- clearInterval(prTimer);
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
  }