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,305 @@
1
+ // Automations: recipes (saved prompts) and triggers (schedules, GitHub watchers).
2
+
3
+ import { esc } from '../md.js';
4
+ import { api } from '../core/api.js';
5
+ import * as bus from '../core/bus.js';
6
+ import { $, $$, el, icon } from '../core/dom.js';
7
+ import { ago, whenLabel } from '../core/format.js';
8
+ import { launch, S } from '../core/state.js';
9
+ import { overlay, present } from '../ui/overlay.js';
10
+ import { toast, toastError } from '../ui/toast.js';
11
+ import { renderFeed, reveal, setFocus } from './feed.js';
12
+ import { launchBehind } from './launcher.js';
13
+ import { projectById } from './places.js';
14
+ import { runnerPicker } from './runner-picker.js';
15
+ import { agentName, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
16
+
17
+ // Top bar: always one click to recipes and triggers. The badge counts triggers that are on;
18
+ // the tooltip says what fires next.
19
+ export async function paintAutoBtn() {
20
+ const btn = $('#autoBtn');
21
+ const on = (S.config.triggers || []).filter((t) => t.enabled);
22
+ const n = $('.ab-n', btn);
23
+ n.hidden = !on.length;
24
+ n.textContent = on.length;
25
+ btn.classList.toggle('live', !!on.length);
26
+ const r = S.config.recipes?.length || 0;
27
+ let title = `Automations: ${r} recipe${r === 1 ? '' : 's'} · ${on.length} trigger${on.length === 1 ? '' : 's'} on`;
28
+ if (on.length) {
29
+ const next = (await api('/triggers').catch(() => [])).filter((t) => t.enabled && t.nextAt).sort((a, b) => a.nextAt - b.nextAt)[0];
30
+ if (next) title += `\nNext: ${next.name} · ${whenLabel(next.nextAt)}`;
31
+ }
32
+ btn.title = title;
33
+ }
34
+
35
+ const TRIGGER_KINDS = [
36
+ { kind: 'schedule', label: 'Schedule', icon: 'clock', hint: 'On a cron schedule, local time' },
37
+ { kind: 'ci', label: 'CI failed', icon: 'warn', hint: 'A GitHub Actions run fails (polled with gh)' },
38
+ { kind: 'issues', label: 'Issue labelled', icon: 'ask', hint: 'An open issue gets a label (polled with gh)' },
39
+ ];
40
+ const CRON_PRESETS = [
41
+ ['Weekdays 9:00', '0 9 * * 1-5'],
42
+ ['Every hour', '0 * * * *'],
43
+ ['Daily 18:00', '0 18 * * *'],
44
+ ['Mondays 8:00', '0 8 * * 1'],
45
+ ];
46
+ export const recipeParams = (prompt) => [...new Set([...String(prompt || '').matchAll(/\{([a-zA-Z][\w-]{0,30})\}/g)].map((m) => m[1]))];
47
+
48
+ const projectOptions = (selected, { any } = {}) =>
49
+ (any ? `<option value="">${esc(any)}</option>` : '') +
50
+ S.config.projects.map((p) => `<option value="${esc(p.path)}" ${p.path === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
51
+ const modeOptions = (selected, { inherit } = {}) =>
52
+ (inherit ? `<option value="">${esc(inherit)}</option>` : '') + MODES.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${m.label}</option>`).join('');
53
+
54
+ export function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
55
+ const node = el(`<div class="modal auto-modal">
56
+ <header><h2>Automations</h2><button class="icon-btn close">${icon('x')}</button></header>
57
+ <p class="mem-lede">Recipes are prompts you send again and again; <code>{name}</code> in one becomes a field to fill in. Triggers start sessions on their own.</p>
58
+ <div class="mem-tabs" role="tablist"></div>
59
+ <div class="auto-body"></div>
60
+ </div>`);
61
+ const close = present(node, host);
62
+ let recipes = [];
63
+ let triggers = [];
64
+ let editing = edit; // { type: 'recipe' | 'trigger', draft }
65
+
66
+ const render = async () => {
67
+ [recipes, triggers] = await Promise.all([api('/recipes').catch(() => recipes), api('/triggers').catch(() => triggers)]);
68
+ if (!node.isConnected) return;
69
+ $('.mem-tabs', node).innerHTML = [
70
+ ['recipes', 'Recipes', recipes.length],
71
+ ['triggers', 'Triggers', triggers.length],
72
+ ].map(([k, l, n]) => `<button role="tab" data-tab="${k}" class="${tab === k ? 'on' : ''}">${l}<span>${n}</span></button>`).join('');
73
+ const body = $('.auto-body', node);
74
+ body.innerHTML = '';
75
+ if (editing?.type === 'recipe') body.append(recipeForm(editing.draft));
76
+ else if (editing?.type === 'trigger') body.append(triggerForm(editing.draft));
77
+ else body.innerHTML = tab === 'recipes' ? recipesView() : triggersView();
78
+ };
79
+
80
+ const recipesView = () =>
81
+ `<div class="au-list">${
82
+ recipes
83
+ .map((r) => {
84
+ const proj = S.config.projects.find((p) => p.path === r.cwd);
85
+ const tags = [r.params.length ? r.params.map((p) => `{${p}}`).join(' ') : '', proj ? proj.name : 'Any project', r.agent ? agentName(r.agent) : '', r.runs ? `${r.runs} run${r.runs > 1 ? 's' : ''}` : ''].filter(Boolean);
86
+ return `<div class="au-item" data-id="${r.id}">
87
+ <span class="mem-ico">${icon('spark')}</span>
88
+ <div class="au-main"><b>${esc(r.name)}</b><p>${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 140))}</p><span class="au-tags">${tags.map((t) => `<i>${esc(t)}</i>`).join('')}</span></div>
89
+ <span class="au-acts"><button class="primary sm" data-act="run">Run</button><button class="icon-btn" data-act="schedule" title="Run it on a schedule">${icon('clock')}</button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
90
+ </div>`;
91
+ })
92
+ .join('') || '<p class="mem-empty">No recipes yet. Save a task you repeat, like a release checklist, a PR from a commit, or a dependency bump.</p>'
93
+ }</div><button class="mem-add" data-act="new">${icon('plus')}New recipe</button>
94
+ <p class="hint">Also from the terminal: <code>wisp recipes</code>, <code>wisp run &lt;name&gt; [values…]</code>.</p>`;
95
+
96
+ const describe = (t) => {
97
+ const proj = S.config.projects.find((p) => p.path === t.cwd)?.name || t.cwd.split(/[\\/]/).pop();
98
+ if (t.kind === 'schedule') return `<code>${esc(t.cron)}</code> in ${esc(proj)}${t.nextAt ? ` · next ${esc(whenLabel(t.nextAt))}` : ''}`;
99
+ const what = t.kind === 'ci' ? `failed CI${t.branch ? ` on <code>${esc(t.branch)}</code>` : ''}` : `issues labelled <code>${esc(t.label)}</code>`;
100
+ return `Watches ${what} in ${esc(proj)} every ${t.every} min${t.seeded ? '' : ' · first check records what is already there'}`;
101
+ };
102
+ const triggersView = () =>
103
+ `<div class="au-list">${
104
+ triggers
105
+ .map((t) => {
106
+ const k = TRIGGER_KINDS.find((x) => x.kind === t.kind);
107
+ const last = t.lastSessionId && S.sessions.has(t.lastSessionId) ? `<a href="#" data-session="${esc(t.lastSessionId)}">last run ${ago(t.lastRunAt)} ago</a>` : t.lastRunAt ? `last run ${ago(t.lastRunAt)} ago` : 'not run yet';
108
+ return `<div class="au-item ${t.enabled ? '' : 'off'}" data-id="${t.id}">
109
+ <span class="mem-ico">${icon(k.icon)}</span>
110
+ <div class="au-main"><b>${esc(t.name)}</b><p>${describe(t)}</p><span class="au-tags"><i>${esc(MODES.find((m) => m.id === t.mode)?.label || t.mode)}</i>${t.agent ? `<i>${esc(agentName(t.agent))}</i>` : ''}<i>${last}</i></span>${t.lastError ? `<p class="au-err">${icon('warn')}${esc(t.lastError)}</p>` : ''}</div>
111
+ <span class="au-acts"><button class="ghost xs" data-act="fire" title="${t.kind === 'schedule' ? 'Run it now' : 'Check GitHub now'}">${icon('replay')}${t.kind === 'schedule' ? 'Run now' : 'Check now'}</button><button class="switch" data-act="toggle" role="switch" aria-checked="${t.enabled}" title="On / off"></button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
112
+ </div>`;
113
+ })
114
+ .join('') || '<p class="mem-empty">No triggers yet. Run a recipe every weekday morning, or send an agent after every red CI run.</p>'
115
+ }</div><button class="mem-add" data-act="new">${icon('plus')}New trigger</button>
116
+ <p class="hint">Triggers run while wisp is open. GitHub watchers use your <code>gh</code> login and poll, so nothing has to reach this machine.</p>`;
117
+
118
+ // Harness · model button for a form, with "Launcher default" when no agent is set.
119
+ const runnerField = (draft) => {
120
+ const wrap = el(`<div class="au-runner"><button type="button" class="runner-btn" aria-haspopup="dialog"></button><button type="button" class="link xs" ${draft.agent ? '' : 'hidden'}>Use launcher default</button></div>`);
121
+ const [btn, reset] = wrap.children;
122
+ const paint = () => (paintRunnerBtn(btn, draft.agent, draft.model, draft.effort), (reset.hidden = !draft.agent));
123
+ btn.onclick = () =>
124
+ runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
125
+ Object.assign(draft, { agent: a, model: m, effort: e });
126
+ paint();
127
+ });
128
+ reset.onclick = () => (Object.assign(draft, { agent: '', model: '', effort: '' }), paint());
129
+ paint();
130
+ return wrap;
131
+ };
132
+
133
+ const recipeForm = (d) => {
134
+ const f = el(`<form class="au-form">
135
+ <label><span>Name</span><input class="input" name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
136
+ <label class="stack"><span>Prompt <small class="muted">· <code>{version}</code> becomes a field</small></span><textarea class="textarea mono" name="prompt" rows="7" spellcheck="false" placeholder="Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}…"></textarea></label>
137
+ <p class="au-params muted xs"></p>
138
+ <label><span>Project</span><select name="cwd">${projectOptions(d.cwd, { any: 'Any project (picked when run)' })}</select></label>
139
+ <label><span>Mode</span><select name="mode">${modeOptions(d.mode, { inherit: 'Launcher default' })}</select></label>
140
+ <div class="au-row"><span>Harness</span></div>
141
+ <div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create recipe'}</button></div>
142
+ </form>`);
143
+ $('[name="name"]', f).value = d.name || '';
144
+ $('[name="prompt"]', f).value = d.prompt || '';
145
+ $('.au-row', f).append(runnerField(d));
146
+ const params = () => {
147
+ const p = recipeParams($('[name="prompt"]', f).value);
148
+ $('.au-params', f).innerHTML = p.length ? `Fields when run: ${p.map((x) => `<code>{${esc(x)}}</code>`).join(' ')}` : 'No fields: it runs as written.';
149
+ };
150
+ $('[name="prompt"]', f).oninput = params;
151
+ params();
152
+ f.onsubmit = async (e) => {
153
+ e.preventDefault();
154
+ const body = { name: $('[name="name"]', f).value, prompt: $('[name="prompt"]', f).value, cwd: $('[name="cwd"]', f).value, mode: $('[name="mode"]', f).value, agent: d.agent || '', model: d.model || '', effort: d.effort || '' };
155
+ try {
156
+ await api(d.id ? `/recipes/${d.id}` : '/recipes', { method: d.id ? 'PATCH' : 'POST', body });
157
+ editing = null;
158
+ tab = 'recipes';
159
+ render();
160
+ } catch (err) {
161
+ toastError(err);
162
+ }
163
+ };
164
+ requestAnimationFrame(() => $(d.name ? '[name="prompt"]' : '[name="name"]', f)?.focus());
165
+ return f;
166
+ };
167
+
168
+ const triggerForm = (d) => {
169
+ d.kind ||= 'schedule';
170
+ const f = el(`<form class="au-form">
171
+ <div class="au-kinds">${TRIGGER_KINDS.map((k) => `<button type="button" data-kind="${k.kind}" class="${k.kind === d.kind ? 'on' : ''}">${icon(k.icon)}<b>${k.label}</b><small>${k.hint}</small></button>`).join('')}</div>
172
+ <label><span>Name</span><input class="input" name="name" maxlength="60" spellcheck="false"></label>
173
+ <label><span>Project</span><select name="cwd">${projectOptions(d.cwd || projectById(launch.project)?.path)}</select></label>
174
+ <div class="k-schedule"><label><span>When</span><input class="input mono" name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
175
+ <p class="au-presets">${CRON_PRESETS.map(([l, c]) => `<button type="button" class="lp-chip" data-cron="${c}">${l}</button>`).join('')}<span class="muted xs">minute hour day month weekday</span></p></div>
176
+ <div class="k-ci"><label><span>Branch</span><input class="input mono" name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
177
+ <div class="k-issues"><label><span>Label</span><input class="input" name="label" spellcheck="false" placeholder="wisp"></label></div>
178
+ <div class="k-gh"><label><span>Check every</span><select name="every">${[2, 5, 15, 60].map((m) => `<option value="${m}" ${Number(d.every || 5) === m ? 'selected' : ''}>${m} min</option>`).join('')}</select></label></div>
179
+ <label><span>Run</span><select name="recipeId"><option value="">This prompt</option>${recipes.map((r) => `<option value="${r.id}" ${r.id === d.recipeId ? 'selected' : ''}>Recipe: ${esc(r.name)}</option>`).join('')}</select></label>
180
+ <label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea class="textarea mono" name="prompt" rows="6" spellcheck="false"></textarea></label>
181
+ <label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
182
+ <div class="au-row"><span>Harness</span></div>
183
+ <p class="au-warn" hidden>${icon('warn')}<span>Issue text comes from GitHub: anyone who can open and label issues can steer this agent. Plan mode only reads; Full auto runs shell commands without asking.</span></p>
184
+ <div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create trigger'}</button></div>
185
+ </form>`);
186
+ for (const k of ['name', 'cron', 'branch', 'label']) $(`[name="${k}"]`, f).value = d[k] || '';
187
+ $('[name="prompt"]', f).value = d.prompt || '';
188
+ $('.au-row', f).append(runnerField(d));
189
+ const sync = () => {
190
+ for (const k of ['schedule', 'ci', 'issues']) $(`.k-${k}`, f).hidden = d.kind !== k;
191
+ $('.k-gh', f).hidden = d.kind === 'schedule';
192
+ $('.k-prompt', f).hidden = !!$('[name="recipeId"]', f).value;
193
+ $('.k-vars', f).innerHTML = d.kind === 'ci' ? '· <code>{name} {branch} {url} {id} {sha}</code> · empty = built-in CI prompt' : d.kind === 'issues' ? '· <code>{number} {title} {body} {url} {author}</code> · empty = built-in issue prompt' : '';
194
+ $('.au-warn', f).hidden = !(d.kind === 'issues' && $('[name="mode"]', f).value !== 'plan');
195
+ $$('.au-kinds button', f).forEach((b) => b.classList.toggle('on', b.dataset.kind === d.kind));
196
+ };
197
+ f.addEventListener('click', (e) => {
198
+ const k = e.target.closest('[data-kind]')?.dataset.kind;
199
+ if (k) {
200
+ if (k === 'issues' && !d.id) $('[name="mode"]', f).value = 'plan';
201
+ d.kind = k;
202
+ sync();
203
+ }
204
+ const c = e.target.closest('[data-cron]')?.dataset.cron;
205
+ if (c) $('[name="cron"]', f).value = c;
206
+ });
207
+ f.addEventListener('change', sync);
208
+ sync();
209
+ f.onsubmit = async (e) => {
210
+ e.preventDefault();
211
+ const v = (k) => $(`[name="${k}"]`, f).value;
212
+ const body = { kind: d.kind, name: v('name'), cwd: v('cwd'), cron: v('cron'), branch: v('branch'), label: v('label') || 'wisp', every: Number(v('every')), recipeId: v('recipeId'), prompt: v('recipeId') ? '' : v('prompt'), mode: v('mode'), agent: d.agent || '', model: d.model || '', effort: d.effort || '' };
213
+ try {
214
+ await api(d.id ? `/triggers/${d.id}` : '/triggers', { method: d.id ? 'PATCH' : 'POST', body });
215
+ editing = null;
216
+ tab = 'triggers';
217
+ render();
218
+ } catch (err) {
219
+ toastError(err);
220
+ }
221
+ };
222
+ return f;
223
+ };
224
+
225
+ node.addEventListener('click', async (e) => {
226
+ const t = e.target.closest('[data-tab],[data-act],[data-session]');
227
+ if (!t || t.closest('.au-form') && !t.dataset.act) return;
228
+ if (t.dataset.tab) return e.preventDefault(), (tab = t.dataset.tab), (editing = null), render();
229
+ if (t.dataset.session) return e.preventDefault(), close(), reveal(t.dataset.session);
230
+ const act = t.dataset.act;
231
+ if (act === 'cancel') return (editing = null), render();
232
+ const itemId = t.closest('.au-item')?.dataset.id;
233
+ if (tab === 'recipes') {
234
+ const r = recipes.find((x) => x.id === itemId);
235
+ if (act === 'new') return (editing = { type: 'recipe', draft: { cwd: projectById(launch.project)?.path || '' } }), render();
236
+ if (act === 'edit') return (editing = { type: 'recipe', draft: { ...r } }), render();
237
+ if (act === 'run') return close(), runRecipe(r);
238
+ if (act === 'schedule') return (tab = 'triggers'), (editing = { type: 'trigger', draft: { kind: 'schedule', name: r.name, recipeId: r.id, cwd: r.cwd || projectById(launch.project)?.path, mode: r.mode || undefined } }), render();
239
+ if (act === 'delete' && confirm(`Delete the recipe “${r.name}”?`)) return api(`/recipes/${r.id}`, { method: 'DELETE' }).then(render).catch(toastError);
240
+ } else {
241
+ const tr = triggers.find((x) => x.id === itemId);
242
+ if (act === 'new') return (editing = { type: 'trigger', draft: {} }), render();
243
+ if (act === 'edit') return (editing = { type: 'trigger', draft: { ...tr } }), render();
244
+ if (act === 'toggle') return api(`/triggers/${tr.id}`, { method: 'PATCH', body: { enabled: !tr.enabled } }).then(render).catch(toastError);
245
+ if (act === 'delete' && confirm(`Delete the trigger “${tr.name}”?`)) return api(`/triggers/${tr.id}`, { method: 'DELETE' }).then(render).catch(toastError);
246
+ if (act === 'fire') {
247
+ t.disabled = true;
248
+ try {
249
+ const r = await api(`/triggers/${tr.id}/run`, { method: 'POST', body: {} });
250
+ for (const x of r.started) S.sessions.set(x.id, x);
251
+ renderFeed();
252
+ toast(r.started.length ? `Started ${r.started.length} session${r.started.length > 1 ? 's' : ''}` : 'Nothing new to act on.');
253
+ } catch (err) {
254
+ toastError(err);
255
+ }
256
+ render();
257
+ }
258
+ }
259
+ });
260
+ bus.on('config', () => !editing && render(), { node });
261
+ render();
262
+ }
263
+
264
+ // Run a recipe: show it, ask for its fields (and a project, if it isn't tied to one), launch on Run.
265
+ export function runRecipe(r) {
266
+ if (!r) return;
267
+ const needsProject = !r.cwd;
268
+ const node = el(`
269
+ <form class="modal run-modal">
270
+ <header><h2>${esc(r.name)}</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
271
+ <p class="cmp-prompt">${esc(r.prompt)}</p>
272
+ ${r.params.map((p) => `<label class="ship-field"><span>${esc(p)}</span><input class="input" name="p:${esc(p)}" spellcheck="false" required></label>`).join('')}
273
+ ${needsProject ? `<label class="ship-field"><span>Project</span><select name="cwd">${projectOptions(projectById(launch.project)?.path)}</select></label>` : ''}
274
+ <div class="ship-foot"><span class="muted xs">${r.agent ? `${esc(agentName(r.agent))}${r.model ? ` · ${esc(r.model)}` : ''}` : 'Launcher harness'} · ${esc(MODES.find((m) => m.id === r.mode)?.label || 'launcher mode')}</span><button class="primary sm">${icon('enter')}Run</button></div>
275
+ </form>`);
276
+ const close = overlay(node);
277
+ $('.close', node).onclick = close;
278
+ node.onsubmit = async (e) => {
279
+ e.preventDefault();
280
+ const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
281
+ const d = S.config.defaults;
282
+ const agent = r.agent || d.agent;
283
+ const cwd = needsProject ? $('[name="cwd"]', node).value : r.cwd;
284
+ const model = r.agent ? r.model : savedModel(agent);
285
+ const effort = r.agent ? r.effort : savedEffort(agent, model);
286
+ const body = { params, cwd: needsProject ? cwd : undefined, agent, model: r.agent ? undefined : model, effort: r.agent ? undefined : effort, mode: r.mode || d.mode };
287
+ close();
288
+ try {
289
+ const prompt = r.prompt.replace(/\{([a-zA-Z][\w-]{0,30})\}/g, (_, k) => params[k] || ''); // as the server fills it
290
+ const s = await launchBehind({ prompt, cwd, runner: { agent, model, effort } }, () => api(`/recipes/${r.id}/run`, { method: 'POST', body }));
291
+ setFocus(s.id);
292
+ } catch (err) {
293
+ toast(`“${esc(r.name)}” didn’t start: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Try again', run: () => runRecipe(r) } });
294
+ }
295
+ };
296
+ // Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
297
+ requestAnimationFrame(() => ($('input, select', node) || $('button.primary', node))?.focus());
298
+ }
299
+
300
+ // A task worth repeating becomes a recipe, starting from its first prompt.
301
+ export async function saveAsRecipe(id) {
302
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
303
+ if (!s?.turns?.[0]) return;
304
+ openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { name: s.title, prompt: s.turns[0].prompt, cwd: '', mode: s.mode, agent: s.agent, model: s.model, effort: s.effort } } });
305
+ }
@@ -0,0 +1,123 @@
1
+ // The ⌘K palette's contents: actions on the focused session, every session, app actions, recipes
2
+ // and project filters. palette.js draws it.
3
+
4
+ import { palette } from '../palette.js';
5
+ import { $ } from '../core/dom.js';
6
+ import { ago, STATUS_LABEL } from '../core/format.js';
7
+ import { S, save } from '../core/state.js';
8
+ import { shortcuts } from '../ui/kbd.js';
9
+ import { closeOverlay, overlay } from '../ui/overlay.js';
10
+ import { deleteSession, rename, rerun, rollbackTurn, setArchived, takeOver, toggleDone } from './actions.js';
11
+ import { openAutomations, recipeParams, runRecipe, saveAsRecipe } from './automations.js';
12
+ import { compareView } from './compare.js';
13
+ import { collapseAll, expand, feedToTop, renderFeed, reveal } from './feed.js';
14
+ import { feedbackOn, openFeedback } from './feedback.js';
15
+ import { focusLauncher, renderLaunchChips } from './launcher.js';
16
+ import { openMachines } from './machines.js';
17
+ import { openMemory } from './memory.js';
18
+ import { openPhone } from './phone.js';
19
+ import { openProjects, renderProjects } from './projects.js';
20
+ import { agentName } from './runners.js';
21
+ import { openSettings } from './settings.js';
22
+ import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
23
+ import { replay } from './turns.js';
24
+ import { openUsage } from './usage.js';
25
+
26
+ const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
27
+
28
+ // Run something on a session's card, expanding it first if the action needs the body.
29
+ function withCard(id, fn, { open = false } = {}) {
30
+ reveal(id);
31
+ if (!open || S.open.has(id)) return setTimeout(fn, 50);
32
+ expand(id).then(fn);
33
+ }
34
+
35
+ function paletteItems() {
36
+ const items = [];
37
+ const cur = S.focus && S.sessions.get(S.focus);
38
+ if (cur) {
39
+ const id = cur.id;
40
+ const full = S.full.get(id);
41
+ const lastTurn = full?.turns?.at(-1);
42
+ const live = cur.state === 'running' || cur.state === 'waiting';
43
+ const canRollback = cur.agent !== 'wisp' && !live && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit || cur.filesChanged);
44
+ const rolledBack = !!lastTurn?.rolledBackAt;
45
+ const g = 'This wisp';
46
+ const sub = cur.title;
47
+ items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
48
+ if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
49
+ if (canRollback) items.push({ group: g, label: rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn', sub, icon: rolledBack ? 'rerun' : 'replay', keywords: 'rollback revert undo checkpoint restore', run: () => rollbackTurn(id, lastTurn?.id, rolledBack) });
50
+ if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
51
+ items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
52
+ items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
53
+ if (shippable(cur)) items.push({ group: g, label: shipLabel(cur), sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
54
+ if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
55
+ if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
56
+ items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
57
+ items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
58
+ items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
59
+ items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
60
+ }
61
+
62
+ const sessions = [...S.sessions.values()].filter((s) => !s.pending).sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
63
+ for (const s of sessions) {
64
+ const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
65
+ items.push({
66
+ group: 'Wisps',
67
+ label: s.title,
68
+ sub: `${s.project} · ${state} · ${ago(s.updatedAt)}`,
69
+ dot: s.color,
70
+ keywords: [s.preview, s.live, s.question, agentName(s.agent)].filter(Boolean).join(' '),
71
+ // Live and unread work floats up; archived sinks.
72
+ rank: (s.state === 'waiting' ? 3 : 0) + (s.unread ? 2 : 0) + (s.state === 'running' ? 1 : 0) - (s.archived ? 4 : 0),
73
+ run: () => reveal(s.id),
74
+ });
75
+ }
76
+
77
+ const a = 'Actions';
78
+ items.push(
79
+ { group: a, label: 'New wisp', icon: 'plus', kbd: 'N', keywords: 'launch create prompt task', run: focusLauncher },
80
+ { group: a, label: S.showDone ? 'Hide done' : 'Show done', icon: 'done', keywords: 'toggle finished', run: () => $('#secDone .toggle-done').click() },
81
+ { group: a, label: S.showArchived ? 'Hide archived' : 'Show archived', icon: 'archive', keywords: 'toggle', run: () => $('#secArchived .toggle-done').click() },
82
+ );
83
+ if (S.open.size) items.push({ group: a, label: 'Collapse all', icon: 'minus', kbd: 'C', run: collapseAll });
84
+ items.push(
85
+ { group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
86
+ { group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
87
+ { group: a, label: 'Memory', icon: 'spark', keywords: 'learned preferences lessons retro', run: () => openMemory() },
88
+ { group: a, label: 'Recipes', icon: 'spark', keywords: 'automations saved prompts templates', run: () => openAutomations({ tab: 'recipes' }) },
89
+ { group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
90
+ { group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
91
+ { group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
92
+ { group: a, label: 'Machines', icon: 'server', keywords: 'remote ssh server computer devbox cloud vm', run: () => openMachines() },
93
+ { group: a, label: 'Add a machine…', icon: 'server', keywords: 'remote ssh server computer connect', run: () => openMachines({ focusAdd: true }) },
94
+ { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
95
+ { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
96
+ );
97
+ if (feedbackOn()) items.push({ group: a, label: 'Send feedback…', icon: 'thumb-up', keywords: 'feedback bug report idea suggestion thumbs contact team', run: () => openFeedback() });
98
+
99
+ for (const r of S.config.recipes || []) items.push({ group: 'Recipes', label: `Run: ${r.name}`, sub: r.prompt.replace(/\s+/g, ' ').slice(0, 80), icon: 'spark', keywords: `recipe ${r.prompt}`, run: () => runRecipe({ ...r, params: recipeParams(r.prompt) }) });
100
+
101
+ const filter = (id) => () => {
102
+ S.filter = id;
103
+ save('wisp.filter', id);
104
+ renderProjects();
105
+ renderFeed();
106
+ feedToTop();
107
+ renderLaunchChips();
108
+ };
109
+ items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
110
+ for (const p of S.config.projects.slice().sort((x, y) => (y.lastUsedAt || y.addedAt) - (x.lastUsedAt || x.addedAt))) {
111
+ items.push({ group: 'Projects', label: p.name, sub: S.filter === p.id ? 'Current filter' : p.path, dot: p.color, keywords: `filter ${p.path}`, run: filter(p.id) });
112
+ }
113
+ return items;
114
+ }
115
+
116
+ let paletteOpen = false;
117
+ export function openPalette() {
118
+ if (paletteOpen) return closeOverlay();
119
+ // Don't stack over another modal (settings, projects…); the palette replaces nothing.
120
+ if (!$('#overlay').hidden) return;
121
+ paletteOpen = true;
122
+ palette({ items: paletteItems(), overlay: (node) => overlay(node, { onClose: () => (paletteOpen = false) }) });
123
+ }
@@ -0,0 +1,104 @@
1
+ // Compare: one task, several harnesses, side by side.
2
+
3
+ import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import { $, el, icon } from '../core/dom.js';
7
+ import { STATUS_LABEL } from '../core/format.js';
8
+ import { S } from '../core/state.js';
9
+ import { overlay } from '../ui/overlay.js';
10
+ import { toast, toastError } from '../ui/toast.js';
11
+ import { renderFeed, reveal } from './feed.js';
12
+ import { loadSessions } from './live.js';
13
+ import { agentName, effortLabel, harnessLogo } from './runners.js';
14
+ import { ctxFor } from './turns.js';
15
+
16
+ function turnMetrics(t) {
17
+ const lines = (t?.changes || []).reduce((acc, c) => {
18
+ const n = countChanges(c.edits);
19
+ return { add: acc.add + n.add, del: acc.del + n.del };
20
+ }, { add: 0, del: 0 });
21
+ const tests = (t?.presentation?.blocks || []).filter((b) => b?.type === 'tests');
22
+ return {
23
+ time: t?.endedAt ? t.endedAt - t.startedAt : null,
24
+ cost: t?.stats?.costUsd,
25
+ files: t?.changes?.length || 0,
26
+ ...lines,
27
+ tests: tests.length ? { passed: tests.reduce((a, b) => a + (Number(b.passed) || 0), 0), failed: tests.reduce((a, b) => a + (Number(b.failed) || 0), 0) } : null,
28
+ asks: (t?.activity || []).filter((a) => a.kind === 'ask').length,
29
+ };
30
+ }
31
+
32
+ export async function compareView(id) {
33
+ const s0 = S.sessions.get(id);
34
+ if (!s0?.groupId) return;
35
+ const group = [...S.sessions.values()].filter((x) => x.groupId === s0.groupId).sort((a, b) => a.createdAt - b.createdAt);
36
+ const fulls = (await Promise.all(group.map((g) => api(`/sessions/${g.id}`).catch(() => null)))).filter(Boolean);
37
+ const node = el(`
38
+ <div class="modal compare-modal">
39
+ <header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
40
+ <p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
41
+ <div class="cmp-grid" style="--n:${fulls.length}"></div>
42
+ </div>`);
43
+ const close = overlay(node);
44
+ $('.close', node).onclick = close;
45
+ const grid = $('.cmp-grid', node);
46
+ for (const f of fulls) {
47
+ const sum = S.sessions.get(f.id) || {};
48
+ const t = f.turns.at(-1);
49
+ const mx = turnMetrics(t);
50
+ const live = f.state === 'running' || f.state === 'waiting';
51
+ const col = el(`
52
+ <section class="cmp-col" data-id="${f.id}">
53
+ <div class="cmp-head">
54
+ <span class="rb-logo">${harnessLogo(f.agent)}</span>
55
+ <span class="cmp-who"><b>${esc(agentName(f.agent))}</b><small>${esc(f.model || 'Default model')}${f.effort ? ` · ${esc(effortLabel(f.effort))}` : ''}</small></span>
56
+ ${live ? `<span class="pill st-waiting"><i></i>${f.state === 'waiting' ? 'Asking' : 'Working'}</span>` : `<span class="pill st-${sum.status || 'info'}"><i></i>${STATUS_LABEL[sum.status] || 'Done'}</span>`}
57
+ </div>
58
+ <dl class="cmp-stats">
59
+ <div><dt>Time</dt><dd>${mx.time !== null ? fmtDuration(mx.time) : '—'}</dd></div>
60
+ <div><dt>Cost</dt><dd>${typeof mx.cost === 'number' ? `$${mx.cost.toFixed(2)}` : '—'}</dd></div>
61
+ <div><dt>Files</dt><dd>${mx.files} <span class="plus">+${mx.add}</span> <span class="minus">−${mx.del}</span></dd></div>
62
+ <div><dt>Tests</dt><dd>${mx.tests ? `<span class="${mx.tests.failed ? 'minus' : 'plus'}">${mx.tests.passed} ✓ ${mx.tests.failed ? `${mx.tests.failed} ✗` : ''}</span>` : '—'}</dd></div>
63
+ <div><dt>Asked</dt><dd>${mx.asks}</dd></div>
64
+ </dl>
65
+ <div class="cmp-body"></div>
66
+ <div class="cmp-foot">
67
+ <button class="ghost xs" data-a="open">${icon('chevron')}Open card</button>
68
+ <span class="grow"></span>
69
+ <button class="primary sm" data-a="keep" ${live ? 'disabled title="Still working"' : ''}>${icon('check')}Keep this</button>
70
+ </div>
71
+ </section>`);
72
+ $('.cmp-body', col).append(live ? el(`<p class="muted xs">${esc(sum.live || 'Working…')}</p>`) : renderPresentation(t?.presentation, ctxFor(f, t, false)));
73
+ grid.append(col);
74
+ }
75
+ grid.onclick = async (e) => {
76
+ const a = e.target.closest('[data-a]')?.dataset.a;
77
+ const keep = e.target.closest('.cmp-col')?.dataset.id;
78
+ if (!a || !keep) return;
79
+ if (a === 'open') return close(), reveal(keep);
80
+ const others = group.filter((g) => g.id !== keep);
81
+ if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
82
+ // Optimistic: the others leave the feed now; stopping and cleaning them up goes on behind.
83
+ for (const o of others) S.sessions.set(o.id, { ...S.sessions.get(o.id), archived: true });
84
+ close();
85
+ renderFeed();
86
+ reveal(keep);
87
+ toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
88
+ let failed = false;
89
+ for (const o of others) {
90
+ if (o.state === 'running' || o.state === 'waiting') {
91
+ await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
92
+ await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
93
+ }
94
+ await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => ((failed = true), toastError(err)));
95
+ }
96
+ if (failed) loadSessions(); // show what really got archived
97
+ };
98
+ }
99
+
100
+ // Poll a condition for up to `ms` (used after asking the server to stop something).
101
+ async function until(fn, ms) {
102
+ const end = Date.now() + ms;
103
+ while (Date.now() < end && !fn()) await new Promise((r) => setTimeout(r, 150));
104
+ }