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.
Files changed (122) hide show
  1. package/CHANGELOG.md +55 -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 +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -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-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
@@ -0,0 +1,69 @@
1
+ // One-click setup for people with no harness yet. opencode is the one wisp installs: its free
2
+ // models work without an account, so a fresh computer can run a task right away. It goes into
3
+ // wisp's own folder (~/.wisp/tools) through npm, which every wisp install already has: no sudo,
4
+ // no shell profile edits, the same on Windows. The opencode adapter finds it there.
5
+
6
+ import fs from 'node:fs';
7
+ import { spawn } from '../proc.js';
8
+ import { bad, toolsDir } from '../util.js';
9
+
10
+ export const INSTALLABLE = {
11
+ opencode: { pkg: 'opencode-ai', name: 'opencode' },
12
+ };
13
+ const TIMEOUT_MS = 5 * 60_000;
14
+
15
+ // npm install --prefix <dir> <pkg>@latest. Resolves when it worked, rejects with the tail of
16
+ // npm's output when it didn't: a 424, since offline or a broken npm isn't a wisp bug (it's
17
+ // counted in the harness_install event instead of reported as an exception).
18
+ export function npmInstall(pkg, dir, { npm = process.env.WISP_NPM || 'npm', timeout = TIMEOUT_MS } = {}) {
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ const env = { ...process.env };
21
+ delete env.ELECTRON_RUN_AS_NODE; // the desktop app's node mode must not leak into npm
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(npm, ['install', '--prefix', dir, '--no-audit', '--no-fund', '--loglevel=error', `${pkg}@latest`], { env, stdio: ['ignore', 'pipe', 'pipe'] });
24
+ let out = '';
25
+ const keep = (d) => (out = (out + d).slice(-4000));
26
+ child.stdout.on('data', keep);
27
+ child.stderr.on('data', keep);
28
+ const timer = setTimeout(() => child.kill(), timeout);
29
+ child.on('error', (err) => (clearTimeout(timer), reject(bad(err.code === 'ENOENT' ? 'npm isn’t on PATH, so wisp can’t install it. Install Node.js (it comes with npm), then try again.' : err.message, 424))));
30
+ child.on('close', (code, signal) => {
31
+ clearTimeout(timer);
32
+ if (code === 0) return resolve();
33
+ const tail = out.trim().split('\n').filter(Boolean).slice(-3).join(' ');
34
+ reject(bad(`npm install ${pkg} ${signal ? 'timed out' : 'failed'}${tail ? `: ${tail}` : ''}`, 424));
35
+ });
36
+ });
37
+ }
38
+
39
+ export function createInstaller(app, { install = npmInstall } = {}) {
40
+ const running = new Map(); // id -> promise: a second click (or tab) waits for the same install
41
+
42
+ // Installs the harness, detects again and returns the harness list. Throws when the install
43
+ // failed or the harness still isn't found afterwards.
44
+ async function run(id) {
45
+ const spec = INSTALLABLE[id];
46
+ if (!spec) throw bad(`wisp can’t install ${id}. Install it yourself, then re-check.`);
47
+ if (!running.has(id)) {
48
+ const job = (async () => {
49
+ const started = Date.now();
50
+ try {
51
+ await install(spec.pkg, toolsDir());
52
+ } catch (err) {
53
+ app.telemetry?.track('harness_install', { agent: id, ok: false, ms: Date.now() - started });
54
+ throw err;
55
+ }
56
+ const agents = await app.auto.detect({ fresh: true });
57
+ const ok = agents.find((a) => a.id === id)?.available;
58
+ app.telemetry?.track('harness_install', { agent: id, ok: !!ok, ms: Date.now() - started });
59
+ if (!ok) throw bad(`${spec.name} was installed but doesn’t start: ${agents.find((a) => a.id === id)?.error || 'unknown error'}`, 500);
60
+ app.broadcast('agents', {});
61
+ return agents;
62
+ })().finally(() => running.delete(id));
63
+ running.set(id, job);
64
+ }
65
+ return running.get(id);
66
+ }
67
+
68
+ return { run, installing: (id) => running.has(id) };
69
+ }
@@ -0,0 +1,127 @@
1
+ // One way in for every launch (the launcher, the CLI, recipes, triggers, the phone and compare
2
+ // runs), here or on a machine, plus the follow-ups and "done" that every surface shares.
3
+
4
+ import fs from 'node:fs';
5
+ import { adapters } from '../adapters/index.js';
6
+ import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
7
+ import { qualify } from '../machines.js';
8
+ import { fill, positional } from '../recipes.js';
9
+ import { summarize } from '../runner.js';
10
+ import { normalizeTeam } from '../subagents.js';
11
+ import { bad, expand, MODES, newId } from '../util.js';
12
+
13
+ export const MAX_COMPARE = 4;
14
+
15
+ export function createLaunch(app) {
16
+ const { store, runner, machines } = app;
17
+
18
+ // body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
19
+ // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
20
+ async function launch(body, origin = null) {
21
+ if (!String(body.prompt || '').trim()) throw bad('prompt required');
22
+ const cwd = expand(body.cwd || process.cwd());
23
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
24
+ const d = store.config.defaults;
25
+ const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
26
+ if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
27
+ const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
28
+ // compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
29
+ // resolved here: the router picks a harness and model (a different one per compare run).
30
+ const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0 });
31
+ const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
32
+ const agent = r.agent || (i ? d.agent : '') || 'claude';
33
+ if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
34
+ return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
35
+ });
36
+ const mode = MODES.has(body.mode) ? body.mode : 'auto';
37
+ // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
38
+ // runs need one each, or the harnesses would edit the same files.
39
+ const comparing = runners.length > 1;
40
+ const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
41
+ // groupId: one launch sent to several machines (or here and there) shows as one task.
42
+ const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
43
+ const attachments = app.uploads.resolve(body.attachments);
44
+ const ids = runners.map(() => newId());
45
+ let trees = ids.map(() => null);
46
+ if (wantTree) {
47
+ const must = body.worktree === true || (comparing && mode !== 'plan');
48
+ // One fetch for the whole launch, then the checkouts side by side: compare runs used to
49
+ // fetch and check out one after the other before anything showed up in the feed.
50
+ const results = await worktreeSource(cwd).then(
51
+ (source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
52
+ (err) => [{ status: 'rejected', reason: err }],
53
+ );
54
+ const failed = results?.find((x) => x.status === 'rejected')?.reason;
55
+ trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
56
+ if (must && (failed || trees.some((t) => !t))) {
57
+ await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
58
+ if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
59
+ 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.');
60
+ }
61
+ if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
62
+ }
63
+ return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
64
+ }
65
+
66
+ // Where a launch runs: body.machine, else a project folder that only exists on a machine
67
+ // (recipes and schedules keep just the path). null = here.
68
+ function machineFor(body) {
69
+ if (body.machine) return machines.get(String(body.machine)).id;
70
+ const cwd = String(body.cwd || '');
71
+ if (!cwd || fs.existsSync(expand(cwd))) return null;
72
+ return store.config.projects.find((p) => p.machine && (cwd === p.path || cwd.startsWith(`${p.path}/`)))?.machine || null;
73
+ }
74
+
75
+ // Attachments are uploaded here; a machine gets its own copies before a launch or reply.
76
+ async function pushUploads(mid, ids) {
77
+ const out = [];
78
+ for (const u of app.uploads.resolve(ids)) out.push((await machines.upload(mid, u)).id);
79
+ return out;
80
+ }
81
+
82
+ // launch(), here or on a machine. Resolves with summaries (a compare launch has several).
83
+ async function launchAny(body, origin = null) {
84
+ const mid = machineFor(body);
85
+ if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
86
+ // Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
87
+ // back through the summaries it streams (auto.signals), so one router learns from every place.
88
+ const routed = await app.auto.resolve(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
89
+ const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
90
+ const sendBody = { ...routed, compare: routed.compare?.map(({ route, ...c }) => c) };
91
+ delete sendBody.route;
92
+ if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
93
+ const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
94
+ (out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
95
+ const known = store.projectFor(out.cwd, mid);
96
+ (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
97
+ store.saveConfig();
98
+ if (!known) app.broadcast('config', {});
99
+ return (out.group || [out]).map((x) => machines.summary(mid, x));
100
+ }
101
+
102
+ // A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
103
+ async function replyTo(s, text, attachments = [], queue, mode, run) {
104
+ await ensureWorktree(s.worktree);
105
+ return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
106
+ }
107
+
108
+ function markDone(s) {
109
+ if (s.state === 'running' || s.state === 'waiting') return false;
110
+ s.state = 'done';
111
+ s.unread = false;
112
+ app.retro.schedule(s, 0);
113
+ app.sessionChanged(s);
114
+ return true;
115
+ }
116
+
117
+ async function runRecipe(r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) {
118
+ const values = words ? { ...positional(r.prompt, words), ...params } : params;
119
+ 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 });
120
+ r.runs = (r.runs || 0) + 1;
121
+ r.lastRunAt = Date.now();
122
+ app.configChanged();
123
+ return s;
124
+ }
125
+
126
+ return { launch, launchAny, machineFor, pushUploads, replyTo, markDone, runRecipe };
127
+ }
@@ -0,0 +1,65 @@
1
+ // Phone (ntfy / Telegram): what a tap or a reply there does to a session, here or on a machine.
2
+
3
+ import { splitId } from '../machines.js';
4
+
5
+ export function phoneActions(app) {
6
+ const { store, runner, machines } = app;
7
+
8
+ // What the phone can do to a session, here or on a machine: { s, answer, reply, done } or null.
9
+ async function sessionOps(sessionId) {
10
+ const at = splitId(sessionId);
11
+ if (!at) {
12
+ const s = store.get(sessionId);
13
+ return s && { s, answer: (a) => runner.answer(s, a), reply: (t) => app.launcher.replyTo(s, t), done: () => app.launcher.markDone(s) };
14
+ }
15
+ const call = (method, p, body) => machines.json(at.machine, method, `/sessions/${at.id}${p}`, body);
16
+ const s = await call('GET', '').catch((err) => {
17
+ if (err.status === 404) return null;
18
+ throw err;
19
+ });
20
+ const live = s && (s.state === 'running' || s.state === 'waiting');
21
+ return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
22
+ }
23
+
24
+ return {
25
+ // /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
26
+ launch: async ({ project, prompt }) => {
27
+ const d = store.config.defaults;
28
+ const agent = d.agent || 'claude';
29
+ const model = d.models?.[agent] ?? d.model ?? '';
30
+ const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'auto' }, { kind: 'telegram', name: 'Telegram' });
31
+ app.broadcast('config', {});
32
+ return s;
33
+ },
34
+ act: async ({ sessionId, kind, ref, index, text }) => {
35
+ const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
36
+ if (ops?.error) return `Can’t reach it: ${ops.error}`;
37
+ if (!ops) return 'That session is gone.';
38
+ const { s } = ops;
39
+ if (kind === 'answer') {
40
+ if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
41
+ const answer = s.ask.options[index];
42
+ if (answer === undefined) return 'Unknown option.';
43
+ await ops.answer(answer);
44
+ return `✓ Answered: ${answer}`;
45
+ }
46
+ if (kind === 'reply') {
47
+ const turn = s.turns.at(-1);
48
+ if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
49
+ const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
50
+ const o = q?.options?.[index];
51
+ const msg = typeof o === 'string' ? o : o?.reply || o?.label;
52
+ if (!msg) return 'Unknown option.';
53
+ await ops.reply(msg);
54
+ return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
55
+ }
56
+ if (kind === 'done') {
57
+ if (s.turns.at(-1)?.id !== ref && ref) return 'There is newer work on that session; open wisp to review it.';
58
+ return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
59
+ }
60
+ if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
61
+ const r = await ops.reply(String(text || '').trim());
62
+ return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,82 @@
1
+ // Ship it: whether a worktree session has something to ship, the draft commit message and PR
2
+ // text, and following open PRs (checks, reviews, merges) so their cards stay current.
3
+
4
+ import fs from 'node:fs';
5
+ import { worktreeStatus, prView } from '../git.js';
6
+ import { blocksToMarkdown } from '../render.js';
7
+ import { summarize } from '../runner.js';
8
+
9
+ const PR_POLL_MS = 60_000;
10
+ const SHIP_RECHECK_MS = 5_000;
11
+
12
+ export function createShip(app) {
13
+ const { store, runner } = app;
14
+
15
+ // Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
16
+ // not yet pushed. Rechecked whenever wisp might have changed the worktree.
17
+ const check = async (s) => {
18
+ if (!s?.worktree || s.parentId) return;
19
+ const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
20
+ const next = { dirty, ahead, unpushed };
21
+ if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
22
+ s.toShip = next;
23
+ app.sessionChanged(s);
24
+ };
25
+ // One check at a time per session (one asked for meanwhile runs right after); `lazy` (a card
26
+ // fetching its session) skips running sessions and runs at most every few seconds.
27
+ const checks = new Map(); // id → true while checking, 'again' if another check is due
28
+ const checkedAt = new Map();
29
+ const recheck = (s, { lazy = false } = {}) => {
30
+ if (!s?.worktree || s.parentId) return;
31
+ if (lazy && (runner.live.has(s.id) || checks.has(s.id) || Date.now() - (checkedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
32
+ if (checks.has(s.id)) return void checks.set(s.id, 'again');
33
+ checks.set(s.id, true);
34
+ checkedAt.set(s.id, Date.now());
35
+ check(s)
36
+ .catch(() => {})
37
+ .finally(() => {
38
+ const again = checks.get(s.id) === 'again';
39
+ checks.delete(s.id);
40
+ if (again) recheck(s);
41
+ });
42
+ };
43
+
44
+ const refreshPr = async (s) => {
45
+ if (!s.pr || !s.worktree) return s.pr;
46
+ const prev = s.pr;
47
+ const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
48
+ const pr = await prView(dir, prev.number);
49
+ const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
50
+ s.pr = pr;
51
+ if (pr.state !== prev.state) app.auto.signals(summarize(s, store));
52
+ // Things worth coming back for: CI finished either way, a review landed, it merged or closed.
53
+ const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
54
+ if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
55
+ app.sessionChanged(s);
56
+ return pr;
57
+ };
58
+
59
+ // Default commit message and PR text, from the session's latest presentation.
60
+ const draft = (s) => {
61
+ const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
62
+ const blocks = turn?.presentation?.blocks || [];
63
+ const hero = blocks.find((b) => b?.type === 'hero');
64
+ const text = `${hero?.title || s.title}`.trim();
65
+ const conventional = /^[a-z]+(\([\w./-]+\))?!?: /.test(text);
66
+ 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';
67
+ const title = conventional ? text : `${type}: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
68
+ const keep = blocks.filter((b) => !['hero', 'next', 'question', 'diff', 'code', 'image', 'terminal'].includes(b?.type));
69
+ const body = [hero?.summary, blocksToMarkdown(keep, turn)].filter(Boolean).join('\n\n').trim();
70
+ return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
71
+ };
72
+
73
+ runner.on('ended', ({ session }) => recheck(store.get(session.id)));
74
+ for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
75
+
76
+ const timer = setInterval(async () => {
77
+ for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
78
+ }, PR_POLL_MS);
79
+ timer.unref();
80
+
81
+ return { recheck, refreshPr, draft, stop: () => clearInterval(timer) };
82
+ }
@@ -0,0 +1,34 @@
1
+ // What the runner, the router and the retro report to telemetry: fixed values about how a turn
2
+ // ran and ended, never the prompt or the result.
3
+
4
+ import { envDisabled } from '../telemetry.js';
5
+
6
+ export const runProps = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
7
+
8
+ // Auto's choice, as fixed values only: never the prompt.
9
+ const routeProps = (s) =>
10
+ 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 } : {};
11
+
12
+ export function trackTurns({ runner, telemetry }) {
13
+ runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...runProps(session, turn), attachments: turn.attachments.length }));
14
+ runner.on('ended', ({ session, turn, interrupted, errorText }) => {
15
+ const blocks = turn.presentation?.blocks || [];
16
+ const st = turn.stats || {};
17
+ telemetry.track('turn_ended', {
18
+ ...runProps(session, turn),
19
+ ...routeProps(session),
20
+ status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
21
+ heroStatus: blocks.find((b) => b.type === 'hero')?.status,
22
+ durationMs: turn.endedAt - turn.startedAt,
23
+ blockCount: blocks.length,
24
+ blockTypes: [...new Set(blocks.map((b) => b.type))],
25
+ presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
26
+ changedFiles: turn.changes.length,
27
+ inputTokens: st.inputTokens,
28
+ outputTokens: st.outputTokens,
29
+ costUsd: st.costUsd,
30
+ });
31
+ if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...runProps(session, turn) });
32
+ });
33
+ if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
34
+ }
@@ -0,0 +1,59 @@
1
+ // Attachments: raw bodies streamed to disk, referenced by id from launches and replies.
2
+ // Each upload is <id>-<name> next to an <id>.json with its metadata.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { MIME } from '../http/static.js';
7
+ import { bad, newId } from '../util.js';
8
+
9
+ export const MAX_UPLOAD = 500 * 1024 * 1024;
10
+
11
+ export class Uploads {
12
+ constructor(dir) {
13
+ this.dir = dir;
14
+ this.byId = new Map(); // id -> { id, name, mime, size, path }
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ for (const f of fs.readdirSync(dir)) {
17
+ if (!f.endsWith('.json')) continue;
18
+ try {
19
+ const u = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
20
+ this.byId.set(u.id, u);
21
+ } catch {}
22
+ }
23
+ }
24
+
25
+ get(id) {
26
+ return this.byId.get(id);
27
+ }
28
+
29
+ // Upload ids from a request body → their records (unknown ids are dropped).
30
+ resolve(ids) {
31
+ return Array.isArray(ids) ? ids.map((i) => this.byId.get(i)).filter(Boolean) : [];
32
+ }
33
+
34
+ async receive(req, { name, type }) {
35
+ name = path.basename(name || 'file').replace(/[^\w.\- ]+/g, '_').slice(-120) || 'file';
36
+ const id = newId();
37
+ const file = path.join(this.dir, `${id}-${name}`);
38
+ let size = 0;
39
+ await new Promise((resolve, reject) => {
40
+ const out = fs.createWriteStream(file);
41
+ req.on('data', (c) => {
42
+ size += c.length;
43
+ if (size > MAX_UPLOAD) {
44
+ req.destroy();
45
+ out.destroy();
46
+ fs.rmSync(file, { force: true });
47
+ reject(bad('file too large (500 MB max)', 413));
48
+ }
49
+ });
50
+ req.pipe(out);
51
+ out.on('finish', resolve);
52
+ out.on('error', reject);
53
+ });
54
+ const meta = { id, name, size, mime: type || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file };
55
+ this.byId.set(id, meta);
56
+ fs.writeFileSync(path.join(this.dir, `${id}.json`), JSON.stringify(meta));
57
+ return meta;
58
+ }
59
+ }
@@ -0,0 +1,28 @@
1
+ // POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
2
+ // event: session.waiting (blocked on ask_user) | session.finished. Fire and forget.
3
+
4
+ import { sessionToMarkdown } from '../render.js';
5
+ import { VERSION } from '../util.js';
6
+
7
+ export function notifyWebhooks(store, summary) {
8
+ const hooks = store.config.webhooks || [];
9
+ if (!hooks.length) return;
10
+ const s = store.get(summary.id);
11
+ if (!s) return;
12
+ const event = s.state === 'waiting' ? 'session.waiting' : 'session.finished';
13
+ const body = JSON.stringify({ event, session: summary, markdown: sessionToMarkdown(s), at: Date.now() });
14
+ for (const h of hooks) {
15
+ if (h.events?.length && !h.events.includes(event)) continue;
16
+ fetch(h.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `wisp/${VERSION}` }, body, signal: AbortSignal.timeout(10_000) }).catch((err) =>
17
+ console.warn(`wisp: webhook ${h.url} failed: ${err.message}`),
18
+ );
19
+ }
20
+ }
21
+
22
+ // A webhook entry from the config API: a URL string or { url, events }.
23
+ export function normalizeWebhooks(list) {
24
+ const hooks = Array.isArray(list) ? list : [];
25
+ const wrong = hooks.find((h) => !/^https?:\/\/\S+$/.test(String(h?.url ?? h)));
26
+ if (wrong !== undefined) return { error: `Not an http(s) URL: ${wrong?.url ?? wrong}` };
27
+ return { hooks: hooks.map((h) => (typeof h === 'string' ? { url: h } : { url: h.url, events: Array.isArray(h.events) ? h.events : undefined })) };
28
+ }
package/server/store.js CHANGED
@@ -5,10 +5,11 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { parsePresentation } from './protocol.js';
8
+ import { writeJsonAtomic } from './util.js';
8
9
 
9
10
  const DEFAULT_CONFIG = {
10
11
  projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
11
- defaults: { agent: 'auto', mode: 'edits', model: '', effort: '' }, // auto: wisp picks harness and model (server/router.js)
12
+ defaults: { agent: 'auto', mode: 'auto', model: '', effort: '' }, // agent auto: wisp picks harness and model (server/router.js)
12
13
  recipes: [], // saved prompts with {params}: server/recipes.js
13
14
  triggers: [], // schedules and GitHub watchers: server/triggers.js
14
15
  teams: [], // saved subagent presets: server/subagents.js
@@ -65,14 +66,22 @@ export class Store {
65
66
  cfg.defaults.effort = '';
66
67
  cfg.autoIntroduced = true;
67
68
  }
69
+ // Full auto became the default mode: switch once from Edits (the old default); Plan and
70
+ // any later pick stick.
71
+ if (!cfg.fullAutoIntroduced) {
72
+ if (cfg.defaults.mode === 'edits') cfg.defaults.mode = 'auto';
73
+ cfg.fullAutoIntroduced = true;
74
+ }
68
75
  return cfg;
69
76
  } catch {
70
- return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true };
77
+ return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true, fullAutoIntroduced: true };
71
78
  }
72
79
  }
73
80
 
74
81
  saveConfig() {
75
- fs.writeFileSync(path.join(this.home, 'config.json'), JSON.stringify(this.config, null, 2));
82
+ // Atomic: a crash mid-write used to leave a cut-off config.json, which loads as the defaults
83
+ // (no projects, recipes, triggers or machines) and is then saved over.
84
+ writeJsonAtomic(path.join(this.home, 'config.json'), this.config, 2);
76
85
  }
77
86
 
78
87
  // --- projects: plain folders, matched to sessions by path prefix ---
@@ -130,8 +139,7 @@ export class Store {
130
139
  this.timers.delete(session.id);
131
140
  if (!this.sessions.has(session.id)) return;
132
141
  const file = path.join(this.dir, `${session.id}.json`);
133
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(session));
134
- fs.renameSync(`${file}.tmp`, file);
142
+ writeJsonAtomic(file, session);
135
143
  };
136
144
  if (now) write();
137
145
  else this.timers.set(session.id, setTimeout(write, 400));
@@ -3,6 +3,7 @@
3
3
  // hidden child session working in the lead's folder; delegating to it again resumes it.
4
4
 
5
5
  import { blocksToMarkdown } from './render.js';
6
+ import { bad } from './util.js';
6
7
 
7
8
  export const MAX_SUBAGENTS = 6; // rows in the team
8
9
  export const MAX_COPIES = 5; // parallel copies of one row
@@ -20,7 +21,7 @@ export function normalizeTeam(list, adapters) {
20
21
  const team = list.slice(0, MAX_SUBAGENTS).map((m) => {
21
22
  const agent = String(m?.agent || '');
22
23
  // auto: wisp picks the harness and model when this member gets its first task.
23
- if ((!adapters[agent] && agent !== 'auto') || agent === 'wisp') throw Object.assign(new Error(`unknown subagent harness: ${agent}`), { status: 400 });
24
+ if ((!adapters[agent] && agent !== 'auto') || agent === 'wisp') throw bad(`unknown subagent harness: ${agent}`);
24
25
  let base = slug(m.name) || agent;
25
26
  let name = base;
26
27
  for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
@@ -30,7 +31,7 @@ export function normalizeTeam(list, adapters) {
30
31
  return { name, agent, model: typeof m.model === 'string' ? m.model.slice(0, 120) : '', effort: EFFORT.test(m.effort || '') ? m.effort : '', use, count };
31
32
  });
32
33
  const total = team.reduce((n, m) => n + m.count, 0);
33
- if (total > MAX_INSTANCES) throw Object.assign(new Error(`At most ${MAX_INSTANCES} subagents in all (this team has ${total}).`), { status: 400 });
34
+ if (total > MAX_INSTANCES) throw bad(`At most ${MAX_INSTANCES} subagents in all (this team has ${total}).`);
34
35
  return team;
35
36
  }
36
37
 
@@ -40,9 +41,9 @@ export const copies = (m) => ((m.count || 1) > 1 ? Array.from({ length: m.count
40
41
  // Saved teams the user can reuse from the launcher: { id, name, members }.
41
42
  export function normalizePreset(body, adapters, prev = {}) {
42
43
  const name = String(body?.name ?? prev.name ?? '').trim().slice(0, 60);
43
- if (!name) throw Object.assign(new Error('A preset needs a name.'), { status: 400 });
44
+ if (!name) throw bad('A preset needs a name.');
44
45
  const members = body?.members !== undefined ? normalizeTeam(body.members, adapters) : prev.members || [];
45
- if (!members.length) throw Object.assign(new Error('A preset needs at least one subagent.'), { status: 400 });
46
+ if (!members.length) throw bad('A preset needs at least one subagent.');
46
47
  return { id: prev.id || slug(name) + '-' + Math.random().toString(36).slice(2, 6), name, members, updatedAt: Date.now() };
47
48
  }
48
49
 
@@ -15,6 +15,7 @@ import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import { VERSION } from './util.js';
18
19
 
19
20
  // PostHog project API key: write-only, safe to ship in the client.
20
21
  const KEY = process.env.WISP_POSTHOG_KEY ?? 'phc_wowwZkbP8e4zYuwT67JtX6oxdPwkMWL5kKnrUmYYXhcD';
@@ -23,7 +24,6 @@ const FLUSH_MS = 15_000;
23
24
  const MAX_QUEUE = 50;
24
25
  const TIMEOUT_MS = 3000;
25
26
 
26
- const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
27
27
  const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
28
28
 
29
29
  const falsy = (v) => /^(0|false|no|off)$/i.test(v || '');
@@ -6,6 +6,7 @@
6
6
  import { spawn } from 'node:child_process';
7
7
  import fs from 'node:fs';
8
8
  import { escapeCmdArgument, escapeCmdCommand, onPath } from './proc.js';
9
+ import { shq } from './util.js';
9
10
 
10
11
  // os: which platforms list the entry (all if omitted).
11
12
  const ALL_TERMINALS = [
@@ -31,7 +32,7 @@ const ALL_TERMINALS = [
31
32
  export const terminalsFor = (platform = process.platform) => ALL_TERMINALS.filter((t) => !t.os || t.os.includes(platform === 'darwin' || platform === 'win32' ? platform : 'linux')).map(({ id, label }) => ({ id, label }));
32
33
  export const TERMINALS = terminalsFor();
33
34
 
34
- export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
35
+ export { shq };
35
36
  const psq = (s) => `'${String(s).replace(/'/g, "''")}'`; // PowerShell single-quoted string
36
37
  const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // AppleScript string
37
38
 
@@ -7,12 +7,11 @@
7
7
 
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { newId } from './runner.js';
10
+ import { bad, MODES, newId } from './util.js';
11
11
  import { gh as ghCli } from './git.js';
12
12
  import { fill, findRecipe } from './recipes.js';
13
13
 
14
14
  const KINDS = new Set(['schedule', 'ci', 'issues']);
15
- const MODES = new Set(['plan', 'edits', 'auto']);
16
15
  const TICK_MS = 20_000;
17
16
  const MAX_SEEN = 200;
18
17
  const MAX_PER_POLL = 3; // a burst of failures shouldn't start a dozen agents
@@ -37,20 +36,20 @@ const ALIASES = { '@hourly': '0 * * * *', '@daily': '0 0 * * *', '@weekly': '0 0
37
36
  export function parseCron(expr) {
38
37
  const src = ALIASES[String(expr || '').trim()] || String(expr || '').trim();
39
38
  const parts = src.split(/\s+/);
40
- if (parts.length !== 5) throw Object.assign(new Error('A schedule needs 5 fields: minute hour day-of-month month day-of-week'), { status: 400 });
39
+ if (parts.length !== 5) throw bad('A schedule needs 5 fields: minute hour day-of-month month day-of-week');
41
40
  const out = {};
42
41
  parts.forEach((part, i) => {
43
42
  const [name, lo, hi] = FIELDS[i];
44
43
  const set = new Set();
45
44
  for (const piece of part.split(',')) {
46
45
  const m = piece.match(/^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/);
47
- if (!m) throw Object.assign(new Error(`Bad ${name} field: ${piece}`), { status: 400 });
46
+ if (!m) throw bad(`Bad ${name} field: ${piece}`);
48
47
  let a = m[1] === '*' ? lo : Number(m[2]);
49
48
  let b = m[1] === '*' ? hi : m[3] !== undefined ? Number(m[3]) : m[4] ? hi : a;
50
49
  const step = m[4] ? Number(m[4]) : 1;
51
50
  if (name === 'dow' && b === 7) b = 6, set.add(0); // 7 is Sunday too
52
51
  if (name === 'dow' && a === 7) a = 0;
53
- if (a < lo || b > hi || a > b || step < 1) throw Object.assign(new Error(`Bad ${name} field: ${piece}`), { status: 400 });
52
+ if (a < lo || b > hi || a > b || step < 1) throw bad(`Bad ${name} field: ${piece}`);
54
53
  for (let v = a; v <= b; v += step) set.add(v);
55
54
  }
56
55
  out[name] = set;
@@ -86,7 +85,6 @@ export function nextRun(c, from = Date.now()) {
86
85
 
87
86
  // --- config ----------------------------------------------------------------------------------
88
87
 
89
- const bad = (msg) => Object.assign(new Error(msg), { status: 400 });
90
88
 
91
89
  export function normalizeTrigger(body, prev = null, { recipes = [] } = {}) {
92
90
  const v = (k, d = '') => (body[k] !== undefined ? body[k] : prev?.[k] !== undefined ? prev[k] : d);
@@ -102,7 +100,7 @@ export function normalizeTrigger(body, prev = null, { recipes = [] } = {}) {
102
100
  if (kind === 'schedule') parseCron(cron);
103
101
  const label = kind === 'issues' ? String(v('label', 'wisp')).trim() : '';
104
102
  if (kind === 'issues' && !label) throw bad('Pick the issue label to watch.');
105
- const mode = String(v('mode', kind === 'issues' ? 'plan' : 'edits'));
103
+ const mode = String(v('mode', kind === 'issues' ? 'plan' : 'auto'));
106
104
  if (!MODES.has(mode)) throw bad('mode must be plan, edits or auto.');
107
105
  const changedSource = prev && (kind !== prev.kind || cwd !== prev.cwd || label !== prev.label || String(v('branch')) !== prev.branch);
108
106
  return {