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
@@ -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,6 +5,7 @@ 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
@@ -72,7 +73,9 @@ export class Store {
72
73
  }
73
74
 
74
75
  saveConfig() {
75
- fs.writeFileSync(path.join(this.home, 'config.json'), JSON.stringify(this.config, null, 2));
76
+ // Atomic: a crash mid-write used to leave a cut-off config.json, which loads as the defaults
77
+ // (no projects, recipes, triggers or machines) and is then saved over.
78
+ writeJsonAtomic(path.join(this.home, 'config.json'), this.config, 2);
76
79
  }
77
80
 
78
81
  // --- projects: plain folders, matched to sessions by path prefix ---
@@ -130,8 +133,7 @@ export class Store {
130
133
  this.timers.delete(session.id);
131
134
  if (!this.sessions.has(session.id)) return;
132
135
  const file = path.join(this.dir, `${session.id}.json`);
133
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(session));
134
- fs.renameSync(`${file}.tmp`, file);
136
+ writeJsonAtomic(file, session);
135
137
  };
136
138
  if (now) write();
137
139
  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);
package/server/update.js CHANGED
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { bad } from './util.js';
12
13
 
13
14
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
14
15
  const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
@@ -79,8 +80,8 @@ export class Updater {
79
80
  // Installs (global) then hands off to a detached helper that waits for this server's port to
80
81
  // free up and starts the new version. The caller shuts this process down.
81
82
  async install() {
82
- if (this.method === 'git') throw Object.assign(new Error('This is a git checkout: run `git pull` to update.'), { status: 400 });
83
- if (this.state.status === 'installing') throw Object.assign(new Error('Already updating.'), { status: 409 });
83
+ if (this.method === 'git') throw bad('This is a git checkout: run `git pull` to update.');
84
+ if (this.state.status === 'installing') throw bad('Already updating.', 409);
84
85
  this.set({ status: 'installing', error: null });
85
86
  try {
86
87
  if (this.method === 'global') await run(`npm install -g ${PKG.name}@latest`);
@@ -109,7 +110,7 @@ function run(cmd) {
109
110
  child.on('exit', (code) => {
110
111
  if (code === 0) return resolve();
111
112
  const hint = /EACCES|permission denied/i.test(err) ? ' (permission denied — run it yourself, maybe with sudo)' : '';
112
- reject(Object.assign(new Error(`\`${cmd}\` failed${hint}: ${err.trim().split('\n').slice(-3).join(' ')}`), { status: 400 }));
113
+ reject(bad(`\`${cmd}\` failed${hint}: ${err.trim().split('\n').slice(-3).join(' ')}`));
113
114
  });
114
115
  });
115
116
  }
package/server/util.js ADDED
@@ -0,0 +1,42 @@
1
+ // Small helpers shared across the server. Nothing here imports another wisp module, so any
2
+ // file can use it without dragging in the runner, the store or an adapter.
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
+ export const DEFAULT_PORT = 4477;
11
+ // The modes a session can run in: read-only planning, edits with approval rules, or full auto.
12
+ export const MODES = new Set(['plan', 'edits', 'auto']);
13
+
14
+ export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
15
+
16
+ // An error the HTTP layer answers with `status` (400 unless said otherwise) and its message.
17
+ export const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
18
+
19
+ // ~ and ~/x → the home folder. `expandHome` keeps the string as typed otherwise; `expand` also resolves it.
20
+ export const expandHome = (p) => String(p).replace(/^~(?=$|[\\/])/, os.homedir());
21
+ export const expand = (p) => path.resolve(expandHome(p));
22
+
23
+ // wisp's own copies of harness CLIs, installed from the app when there's none (~/.wisp/tools).
24
+ export const toolsDir = () => path.join(process.env.WISP_HOME || path.join(os.homedir(), '.wisp'), 'tools');
25
+
26
+ // Single-quote for a POSIX shell.
27
+ export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
28
+
29
+ // One line, at most n characters (whitespace collapsed).
30
+ export function clip(s, n = 80) {
31
+ s = String(s ?? '').replace(/\s+/g, ' ').trim();
32
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
33
+ }
34
+
35
+ // Write JSON so a crash mid-write never leaves a half file behind: tmp file, then rename.
36
+ export function writeJsonAtomic(file, data, space) {
37
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(data, null, space));
38
+ fs.renameSync(`${file}.tmp`, file);
39
+ }
40
+
41
+ // At most n characters, line breaks kept (messages, not one-liners).
42
+ export const truncate = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
package/server/welcome.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // using the same blocks an agent would. Doubles as a living block gallery.
3
3
 
4
4
  import os from 'node:os';
5
- import { newId } from './runner.js';
5
+ import { newId } from './util.js';
6
6
 
7
7
  export function welcomeSession() {
8
8
  const now = Date.now();
@@ -0,0 +1,29 @@
1
+ /* Auto: wisp's router as a harness (picker, card badge, Settings → Auto) */
2
+ .auto-mark { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
3
+ .auto-mark .i { width: 100%; height: 100%; }
4
+ .model-logo.routed::before { content: ''; position: absolute; right: -3px; bottom: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 1.5px var(--surface); }
5
+ .pk-note { margin: 8px 10px 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
6
+ .pk-note a { color: var(--accent-ink); }
7
+ .ro-goal { flex: none; }
8
+ .ro-allowed { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; max-width: 360px; }
9
+ .chip-tog { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
10
+ .chip-tog:hover { color: var(--ink); }
11
+ .chip-tog.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
12
+ .router-modal { width: min(720px, 100%); position: relative; padding-bottom: 20px; }
13
+ .router-modal .hint { margin: 0 0 12px; }
14
+ .router-modal h4 { margin: 16px 0 6px; }
15
+ .ro-table td small.ro-types { display: block; margin: 2px 0 0 19px; font-size: 11px; color: var(--faint); }
16
+ .ro-table .sx-state { justify-content: flex-end; }
17
+ .sx-state.ok { color: var(--good); }
18
+ .sx-state.warn { color: var(--warn); }
19
+ .dot.warn { background: var(--warn); }
20
+ .ro-recent { list-style: none; margin: 0; padding: 0; }
21
+ .ro-recent li { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
22
+ .ro-recent .ro-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
23
+ .ro-recent small { flex: none; color: var(--faint); }
24
+ .ro-recent .ok { color: var(--good); }
25
+ .ro-recent .warn { color: var(--warn); }
26
+ .ro-recent .bad { color: var(--bad); }
27
+ .ro-recent .dim { color: var(--faint); }
28
+ .ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
29
+ .ro-foot .hint { margin: 0; }
@@ -0,0 +1,71 @@
1
+ /* ---------- automations, usage, phone ---------- */
2
+ .auto-modal, .usage-modal, .phone-modal { width: min(720px, 100%); position: relative; }
3
+ .usage-modal { padding-bottom: 24px; }
4
+ .mem-lede code { font-size: 11.5px; padding: 1px 5px; border-radius: 5px; background: var(--bg-2); }
5
+ .auto-body { padding-top: 10px; }
6
+ .au-list { display: flex; flex-direction: column; gap: 2px; }
7
+ .au-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 8px; border-radius: 10px; }
8
+ .au-item:hover { background: var(--surface-2); }
9
+ .au-item.off .au-main { opacity: 0.55; }
10
+ .au-main { flex: 1; min-width: 0; }
11
+ .au-main b { font-size: 13.5px; }
12
+ .au-main p { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
13
+ .au-main code { font-size: 11.5px; padding: 0 4px; border-radius: 4px; background: var(--bg-2); }
14
+ .au-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
15
+ .au-tags i { font-style: normal; font-size: 11px; padding: 1px 6px; border-radius: 5px; background: var(--bg-2); color: var(--ink-2); }
16
+ .au-tags a { color: var(--accent-ink); }
17
+ .au-err { display: flex; align-items: center; gap: 5px; color: var(--bad) !important; }
18
+ .au-err .i { width: 12px; height: 12px; }
19
+ .au-acts { display: flex; align-items: center; gap: 4px; flex: none; }
20
+ .au-form { display: flex; flex-direction: column; gap: 0; }
21
+ .au-form > label, .au-form > div > label, .au-row { display: flex; align-items: center; gap: 14px; padding: 8px 0; border-bottom: 1px solid var(--line); }
22
+ .au-form label > span, .au-row > span { width: 92px; flex: none; font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
23
+ .au-form label.stack { flex-direction: column; align-items: stretch; gap: 6px; }
24
+ .au-form label.stack > span { width: auto; }
25
+ .au-form :is(.input, .textarea, select) { flex: 1; }
26
+ .au-form [hidden] { display: none !important; }
27
+ .au-params { margin: 6px 0 2px; }
28
+ .au-params code, .k-vars code { font-size: 11px; }
29
+ .au-runner { display: flex; align-items: center; gap: 10px; }
30
+ .au-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 6px 0 4px 106px; }
31
+ .au-kinds { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 6px; }
32
+ .au-kinds button { display: grid; grid-template-columns: 16px 1fr; column-gap: 8px; text-align: left; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); }
33
+ .au-kinds button .i { grid-row: span 2; margin-top: 2px; color: var(--muted); }
34
+ .au-kinds button b { font-size: 13px; }
35
+ .au-kinds button small { font-size: 11px; color: var(--muted); }
36
+ .au-kinds button.on { border-color: var(--accent); background: var(--accent-soft); }
37
+ .au-kinds button.on .i { color: var(--accent); }
38
+ .au-warn { display: flex; gap: 8px; margin: 10px 0 0; padding: 8px 10px; border-radius: 9px; font-size: 12.5px; color: var(--warn); background: color-mix(in srgb, var(--warn) 8%, transparent); }
39
+ .au-warn .i { flex: none; margin-top: 1px; }
40
+ .au-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
41
+
42
+ .usage-modal header { gap: 12px; }
43
+ .usage-modal header h2 { margin-right: auto; }
44
+ .us-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
45
+ .us-tiles div { display: flex; flex-direction: column; padding: 10px 12px; border-radius: 11px; border: 1px solid var(--line); background: var(--surface-2); }
46
+ .us-tiles span { font-size: 11.5px; color: var(--muted); }
47
+ .us-tiles b { font: 600 22px/1.3 var(--display); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
48
+ .us-tiles small { font-size: 11px; color: var(--faint); }
49
+ .us-chart { display: flex; align-items: flex-end; gap: 2px; height: 120px; padding-bottom: 1px; border-bottom: 1px solid var(--line-2); }
50
+ .us-bar { flex: 1; height: 100%; display: flex; align-items: flex-end; cursor: default; }
51
+ .us-bar i { display: block; width: 100%; border-radius: 4px 4px 0 0; background: var(--accent); opacity: 0.85; }
52
+ .us-bar:hover i { opacity: 1; }
53
+ .us-axis { display: flex; justify-content: space-between; margin-top: 4px; font-size: 11px; color: var(--faint); }
54
+ .us-tip { position: absolute; z-index: 5; transform: translate(-50%, -100%); padding: 3px 7px; border-radius: 6px; background: var(--ink); color: var(--bg); font-size: 11px; white-space: nowrap; pointer-events: none; }
55
+ .us-table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
56
+ .us-table th { text-align: right; font-weight: 500; font-size: 11px; color: var(--faint); padding: 4px 6px; border-bottom: 1px solid var(--line); }
57
+ .us-table td { text-align: right; padding: 6px; border-bottom: 1px solid var(--line); color: var(--ink-2); }
58
+ .us-table :is(th, td):first-child { text-align: left; color: var(--ink); }
59
+ .us-proj, .us-runner { display: inline-flex; align-items: center; gap: 6px; }
60
+
61
+ .phone-modal .ph-sec h4 { display: flex; align-items: center; gap: 8px; }
62
+ .phone-modal .ph-sec h4 .switch { margin-left: auto; }
63
+ .phone-modal .settings-modal { width: auto; padding: 0; border: 0; box-shadow: none; background: none; }
64
+ .settings-modal label.check { padding: 0; border: 0; gap: 6px; }
65
+ .ph-ok, .ph-bad { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 500; }
66
+ .ph-ok { color: var(--good); }
67
+ .ph-bad { color: var(--bad); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
68
+ .ph-ok .i, .ph-bad .i { width: 12px; height: 12px; }
69
+ .ph-start { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
70
+ .ph-row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 6px; }
71
+
@@ -0,0 +1,83 @@
1
+ /* ---------- buttons ---------- */
2
+ .primary {
3
+ display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px 0 16px;
4
+ border-radius: 8px; background: var(--ink); color: var(--bg); font-weight: 500;
5
+ transition: opacity 0.15s;
6
+ }
7
+ .primary:hover { opacity: 0.86; }
8
+ .primary:disabled { opacity: 0.5; }
9
+ .primary kbd { background: transparent; color: inherit; border-color: color-mix(in srgb, var(--bg) 35%, transparent); opacity: 0.7; }
10
+ .primary.sm { height: 30px; padding: 0 12px; font-size: 13px; border-radius: 9px; }
11
+ .ghost {
12
+ display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 9px;
13
+ color: var(--ink-2); transition: background 0.15s, color 0.15s;
14
+ }
15
+ .ghost:hover { background: var(--bg-2); color: var(--ink); }
16
+ .ghost.on { color: var(--good); }
17
+ .ghost.sm { height: 28px; font-size: 13px; }
18
+ .ghost.xs { height: 24px; padding: 0 6px; border-radius: 7px; }
19
+ .ghost.danger:hover { color: var(--bad); background: var(--del-bg); }
20
+ .icon-btn {
21
+ display: inline-grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; color: var(--muted);
22
+ transition: background 0.15s, color 0.15s;
23
+ }
24
+ .icon-btn:hover { background: var(--bg-2); color: var(--ink); }
25
+ .link { color: var(--accent-ink); font-weight: 500; }
26
+ .link:hover { text-decoration: underline; text-underline-offset: 3px; }
27
+ .link.on { color: var(--ink); }
28
+ /* ---------- form kit ----------
29
+ One look for every standalone control. Use these classes, don't restyle controls per component:
30
+ <input class="input"> <textarea class="textarea"> <select> (styled by default)
31
+ .mono code-ish values: paths, cron, tokens, commands
32
+ .sm / .lg 30px rows in dense panels / the 38px add-project row
33
+ .field a box holding an icon / prefix + a bare <input>: <label class="field">icon<input></label>
34
+ .check a label wrapping a checkbox and its text
35
+ Components set only layout (width, flex) on these; a container already tinted surface-2 sets
36
+ --field-bg: var(--surface) so its fields don't melt into it. Prompt boxes (launcher, composer, rerun) and
37
+ inline renames (project name, memory edit) are their own, deliberately borderless pattern. */
38
+ :is(.input, .textarea, select, .field) {
39
+ width: 100%; min-width: 0; height: 32px; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line-2);
40
+ background-color: var(--field-bg, var(--surface-2)); color: var(--ink); font: 13px/1.4 var(--sans); outline: 0;
41
+ transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
42
+ }
43
+ :is(.input, .textarea, select, .field):hover { border-color: color-mix(in srgb, var(--ink) 14%, var(--line-2)); }
44
+ :is(.input, .textarea, select):focus, .field:focus-within {
45
+ border-color: var(--accent); background-color: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft);
46
+ }
47
+ :is(.input, .textarea, .field input)::placeholder { color: var(--faint); }
48
+ :is(.input, .textarea, select):disabled { opacity: 0.55; cursor: not-allowed; }
49
+ :is(.input, .textarea, select):is([aria-invalid='true'], :user-invalid) { border-color: var(--bad); }
50
+ :is(.input, .textarea, select):is([aria-invalid='true'], :user-invalid):focus { box-shadow: 0 0 0 3px var(--del-bg); }
51
+ .textarea { display: block; height: auto; min-height: 64px; padding: 7px 10px; line-height: 1.5; resize: vertical; }
52
+ :is(.input, .textarea, .field).mono { font-family: var(--mono); font-size: 12.5px; }
53
+ :is(.input, .field).sm { height: 30px; padding: 0 8px; }
54
+ :is(.input, .field).lg { height: 38px; padding: 0 12px; border-radius: 11px; }
55
+ select:not([multiple]) {
56
+ width: auto; padding-right: 28px; appearance: none; cursor: pointer; text-overflow: ellipsis;
57
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
58
+ background-position: calc(100% - 14px) 50%, calc(100% - 10px) 50%; background-size: 4px 4px; background-repeat: no-repeat;
59
+ }
60
+ .field { display: flex; align-items: center; gap: 8px; color: var(--muted); }
61
+ .field .i { width: 14px; height: 14px; flex: none; }
62
+ .field > input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0; background: none; font: inherit; color: var(--ink); }
63
+ input[type='checkbox'] { width: 15px; height: 15px; margin: 0; flex: none; accent-color: var(--accent); cursor: pointer; }
64
+ .check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); cursor: pointer; }
65
+
66
+ /* ---------- the mark ---------- */
67
+ /* A plain dot. Brand: ink dot with an offset accent dot. Live: a thin spinner ring. */
68
+ .orb { --s: 14px; position: relative; display: inline-block; width: var(--s); height: var(--s); border-radius: 50%; background: var(--ink); flex: none; }
69
+ .orb i { display: none; }
70
+ .orb.tiny { --s: 12px; }
71
+ .orb.tiny::after {
72
+ content: ''; position: absolute; width: 6px; height: 6px; right: -4px; bottom: -2px; border-radius: 50%;
73
+ background: var(--accent); box-shadow: 0 0 0 2px var(--bg);
74
+ }
75
+ .orb.big { display: none; }
76
+ .orb.live {
77
+ --s: 18px; background: none; border: 2px solid var(--line-2); border-top-color: var(--accent);
78
+ animation: orb-spin 0.9s linear infinite;
79
+ }
80
+ .waiting .orb.live { border: 0; background: var(--warn); --s: 10px; margin: 4px; animation: pulse 1.6s ease-in-out infinite; }
81
+
82
+ @keyframes orb-spin { to { rotate: 360deg; } }
83
+