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.
- package/CHANGELOG.md +50 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +56 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +339 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +87 -36
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +62 -0
- package/web/css/feed.css +208 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +16 -1
- package/web/js/app.js +28 -5232
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +339 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +104 -0
- package/web/js/features/composer.js +311 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +559 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +161 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1901
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Machines: other computers, over SSH. A machine runs its own wisp; this one signs in over SSH,
|
|
2
|
+
// tunnels to it and shows its sessions as <id>@<machine>. Projects can live on a machine; launching
|
|
3
|
+
// in one runs the agent there.
|
|
4
|
+
|
|
5
|
+
import { copy } from '../blocks.js';
|
|
6
|
+
import { esc } from '../md.js';
|
|
7
|
+
import { api } from '../core/api.js';
|
|
8
|
+
import * as bus from '../core/bus.js';
|
|
9
|
+
import { $, el, icon } from '../core/dom.js';
|
|
10
|
+
import { S } from '../core/state.js';
|
|
11
|
+
import { present } from '../ui/overlay.js';
|
|
12
|
+
import { popover } from '../ui/popover.js';
|
|
13
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
14
|
+
import { renderLaunchChips } from './launcher.js';
|
|
15
|
+
import { loadConfig, loadSessions } from './live.js';
|
|
16
|
+
import { MACHINE_STATE, machineById, machinesSummary } from './places.js';
|
|
17
|
+
import { openProjects, renderProjects } from './projects.js';
|
|
18
|
+
import { harnessLogo } from './runners.js';
|
|
19
|
+
|
|
20
|
+
export const loadMachines = () => api('/machines').then(setMachines).catch(() => {});
|
|
21
|
+
export function setMachines(list) {
|
|
22
|
+
const was = new Map(S.machines.map((m) => [m.id, m.state === 'online']));
|
|
23
|
+
S.machines = list;
|
|
24
|
+
paintMachinesBtn();
|
|
25
|
+
renderLaunchChips();
|
|
26
|
+
$('#launcher')?.placesChanged?.();
|
|
27
|
+
renderProjects();
|
|
28
|
+
bus.emit('machines');
|
|
29
|
+
// Coming online lists its sessions; going offline greys their cards.
|
|
30
|
+
if (list.some((m) => (was.get(m.id) || false) !== (m.state === 'online'))) loadSessions();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function paintMachinesBtn() {
|
|
34
|
+
const b = $('#machinesBtn');
|
|
35
|
+
if (!b) return;
|
|
36
|
+
const on = S.machines.filter((m) => m.state === 'online').length;
|
|
37
|
+
const trouble = S.machines.some((m) => m.state === 'error' || m.state === 'reconnecting');
|
|
38
|
+
b.classList.toggle('live', on > 0);
|
|
39
|
+
b.classList.toggle('trouble', trouble);
|
|
40
|
+
const n = $('.ab-n', b);
|
|
41
|
+
n.hidden = !on;
|
|
42
|
+
n.textContent = on;
|
|
43
|
+
b.title = S.machines.length ? `Machines: ${machinesSummary()}` : 'Machines: run agents on another computer over SSH';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function connectMachine(id) {
|
|
47
|
+
return tryApi(() => api(`/machines/${id}/connect`, { method: 'POST', body: {} }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function offlineToast(id) {
|
|
51
|
+
const m = machineById(id);
|
|
52
|
+
if (m?.state === 'connecting' || m?.state === 'reconnecting') return toast(`Still connecting to <b>${esc(m.name)}</b>…`);
|
|
53
|
+
toast(`<b>${esc(m?.name || 'That machine')}</b> isn’t connected`, { tone: 'bad', action: { label: 'Connect', run: () => connectMachine(id) } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const STEP_ICON = { done: 'check', fail: 'x', warn: 'warn', run: '', pending: '' };
|
|
57
|
+
|
|
58
|
+
export function openMachines({ host, focusAdd = false } = {}) {
|
|
59
|
+
const node = el(`<div class="modal machines-modal">
|
|
60
|
+
<header><h2>Machines</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
61
|
+
<div class="mc-body">
|
|
62
|
+
<p class="mem-lede">Run agents on your other computers: a dev box, a GPU server, a cloud VM. wisp signs in over SSH, starts itself there and tunnels back, so worktrees, diffs and Ship it work as usual, and agents keep going when this computer sleeps.</p>
|
|
63
|
+
<div class="mc-list"></div>
|
|
64
|
+
<section class="mc-add"></section>
|
|
65
|
+
</div>
|
|
66
|
+
</div>`);
|
|
67
|
+
const close = present(node, host);
|
|
68
|
+
const body = $('.mc-body', node);
|
|
69
|
+
let editing = null; // machine id whose form is open, '' = a new one, null = closed
|
|
70
|
+
let hosts = []; // found on this computer: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts
|
|
71
|
+
api('/machines/discover')
|
|
72
|
+
.then((h) => {
|
|
73
|
+
hosts = h;
|
|
74
|
+
const slot = $('.mc-found-slot', node);
|
|
75
|
+
if (slot) slot.innerHTML = found();
|
|
76
|
+
const dl = $('#sshHosts', node);
|
|
77
|
+
if (dl) dl.innerHTML = datalist();
|
|
78
|
+
})
|
|
79
|
+
.catch(() => {});
|
|
80
|
+
const datalist = () => hosts.map((x) => `<option value="${esc(x.target)}">${esc(x.detail || '')}</option>`).join('');
|
|
81
|
+
const SOURCE = { 'ssh-config': '~/.ssh/config', tailscale: 'Tailscale', 'known-hosts': 'You’ve SSHed here before' };
|
|
82
|
+
// Hosts not added yet, one click from a filled-in form.
|
|
83
|
+
const found = () => {
|
|
84
|
+
const added = new Set(S.machines.map((m) => m.target));
|
|
85
|
+
const list = hosts.filter((x) => !added.has(x.target));
|
|
86
|
+
if (!list.length) return '';
|
|
87
|
+
return `<div class="mc-found"><h4>Found on this computer</h4><div class="mc-found-list">${list
|
|
88
|
+
.slice(0, 12)
|
|
89
|
+
.map(
|
|
90
|
+
(x) =>
|
|
91
|
+
`<button type="button" class="lp-chip${x.online === false ? ' off' : ''}" data-a="pick" data-target="${esc(x.target)}" data-name="${esc(x.name)}" title="${esc([SOURCE[x.source], x.target, x.detail].filter(Boolean).join(' · '))}">${icon('server')}${esc(x.name)}${x.online !== undefined ? `<i class="mc-fdot"></i>` : ''}</button>`,
|
|
92
|
+
)
|
|
93
|
+
.join('')}</div></div>`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Projects on a machine open here, with a way back.
|
|
97
|
+
const sub = (view) => {
|
|
98
|
+
const head = $(':scope > header', node);
|
|
99
|
+
body.hidden = true;
|
|
100
|
+
head.hidden = true;
|
|
101
|
+
view.classList.add('embedded');
|
|
102
|
+
const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
|
|
103
|
+
back.onclick = () => {
|
|
104
|
+
view.remove();
|
|
105
|
+
body.hidden = false;
|
|
106
|
+
head.hidden = false;
|
|
107
|
+
paint();
|
|
108
|
+
};
|
|
109
|
+
$('header', view).prepend(back);
|
|
110
|
+
$('.close', view).onclick = close;
|
|
111
|
+
node.append(view);
|
|
112
|
+
return close;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const steps = (m) => `<ol class="mc-steps">${m.steps
|
|
116
|
+
.map((st) => `<li class="s-${st.status}"><span class="mc-si">${STEP_ICON[st.status] ? icon(STEP_ICON[st.status]) : ''}</span><b>${esc(st.label)}</b>${st.detail ? `<small>${esc(st.detail)}</small>` : ''}</li>`)
|
|
117
|
+
.join('')}</ol>`;
|
|
118
|
+
const trouble = (m) => `<div class="mc-hint ${m.state === 'reconnecting' ? 'soft' : ''}">
|
|
119
|
+
<p><b>${esc(m.state === 'reconnecting' ? `${m.error || 'Connection lost.'} Trying again${m.attempt > 1 ? ` (attempt ${m.attempt})` : ''}…` : m.error)}</b></p>
|
|
120
|
+
${m.hint?.text ? `<p>${esc(m.hint.text)}</p>` : ''}
|
|
121
|
+
${m.hint?.command ? `<div class="mc-cmd"><code>${esc(m.hint.command)}</code><button class="ghost xs" data-a="copy" data-cmd="${esc(m.hint.command)}" title="Copy">${icon('copy')}</button></div>` : ''}
|
|
122
|
+
${m.stale ? `<button class="ghost sm" data-a="restart">${icon('rerun')}Restart wisp there</button>` : ''}
|
|
123
|
+
${m.log.length ? `<details class="mc-log"><summary>Log</summary><pre>${esc(m.log.join('\n'))}</pre></details>` : ''}
|
|
124
|
+
</div>`;
|
|
125
|
+
const projects = (m) => {
|
|
126
|
+
const list = S.config.projects.filter((p) => p.machine === m.id);
|
|
127
|
+
return `<div class="mc-projects">${list.map((p) => `<span class="lp-chip c-${p.color}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</span>`).join('')}
|
|
128
|
+
<button class="lp-chip add" data-a="projects">${icon(list.length ? 'plus' : 'folder')}${list.length ? 'Add' : 'Pick projects on it…'}</button></div>`;
|
|
129
|
+
};
|
|
130
|
+
const card = (m) => {
|
|
131
|
+
const st = m.state;
|
|
132
|
+
const busy = st === 'connecting' || st === 'reconnecting';
|
|
133
|
+
const ready = (m.remote?.agents || []).filter((a) => a.available);
|
|
134
|
+
return `<div class="mc st-${st}" data-id="${m.id}">
|
|
135
|
+
<div class="mc-head">
|
|
136
|
+
<span class="mc-icon">${icon('server')}<i class="mc-dot"></i></span>
|
|
137
|
+
<div class="mc-title"><b>${esc(m.name)}</b><span>${esc(m.target)}${m.port !== 4477 ? ` · port ${m.port}` : ''}</span></div>
|
|
138
|
+
<span class="mc-state">${MACHINE_STATE[st] || st}</span>
|
|
139
|
+
${st === 'online' || busy ? `<button class="ghost sm" data-a="disconnect">${busy ? 'Cancel' : 'Disconnect'}</button>` : `<button class="primary sm" data-a="connect">${st === 'error' ? 'Try again' : 'Connect'}</button>`}
|
|
140
|
+
<button class="icon-btn" data-a="menu" title="More">${icon('dots')}</button>
|
|
141
|
+
</div>
|
|
142
|
+
${
|
|
143
|
+
st === 'online'
|
|
144
|
+
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
145
|
+
: ''
|
|
146
|
+
}
|
|
147
|
+
${busy || st === 'error' ? steps(m) : ''}
|
|
148
|
+
${(st === 'error' || st === 'reconnecting') && m.error ? trouble(m) : ''}
|
|
149
|
+
${st === 'online' || m.projects ? projects(m) : ''}
|
|
150
|
+
${editing === m.id ? `<div class="mc-edit">${form(m)}</div>` : ''}
|
|
151
|
+
</div>`;
|
|
152
|
+
};
|
|
153
|
+
const form = (m) => `<form class="mc-form" data-id="${m?.id || ''}">
|
|
154
|
+
<div class="mc-row">
|
|
155
|
+
<label class="mc-field grow"><span>SSH host</span><input class="input mono" name="target" list="sshHosts" placeholder="me@devbox, 10.0.0.5, or a Host from ~/.ssh/config" spellcheck="false" autocomplete="off" autocapitalize="off" value="${esc(m?.target || '')}" required></label>
|
|
156
|
+
<label class="mc-field"><span>Name</span><input class="input" name="name" placeholder="${esc(m?.name || 'devbox')}" value="${esc(m?.name || '')}" spellcheck="false"></label>
|
|
157
|
+
</div>
|
|
158
|
+
<details class="mc-adv" ${m && (m.port !== 4477 || m.command) ? 'open' : ''}><summary>Advanced</summary>
|
|
159
|
+
<div class="mc-row">
|
|
160
|
+
<label class="mc-field"><span>wisp’s port there</span><input class="input mono" name="port" type="number" min="1" max="65535" value="${m?.port || 4477}"></label>
|
|
161
|
+
<label class="mc-field grow"><span>Start command</span><input class="input mono" name="command" placeholder="${esc(S.machines[0]?.defaultCommand || 'npx -y wisp-agents@<this version>')}" value="${esc(m?.command || '')}" spellcheck="false"></label>
|
|
162
|
+
</div>
|
|
163
|
+
<label class="check mc-check"><input type="checkbox" name="autoConnect" ${m?.autoConnect === false ? '' : 'checked'}> Connect when wisp starts</label>
|
|
164
|
+
</details>
|
|
165
|
+
<div class="mc-foot">
|
|
166
|
+
<p class="hint">${m ? 'Changing the host, port or command reconnects.' : 'Uses your SSH keys, agent and ~/.ssh/config; wisp never asks for a password. The machine needs Node.js 20+ and the harness CLIs, signed in.'}</p>
|
|
167
|
+
${editing !== null && (m || S.machines.length) ? '<button type="button" class="ghost sm" data-a="cancel">Cancel</button>' : ''}
|
|
168
|
+
<button class="primary sm" type="submit">${m ? 'Save' : `${icon('server')}Connect`}</button>
|
|
169
|
+
</div>
|
|
170
|
+
</form>`;
|
|
171
|
+
|
|
172
|
+
const paint = () => {
|
|
173
|
+
if (!node.isConnected && !host) return;
|
|
174
|
+
$('.mc-list', node).innerHTML = S.machines.map(card).join('');
|
|
175
|
+
const add = $('.mc-add', node);
|
|
176
|
+
// The form for a new machine: open when there are none yet, else behind a button.
|
|
177
|
+
if (!S.machines.length || editing === '') {
|
|
178
|
+
// Keep a half-filled form; only the suggestions above it change.
|
|
179
|
+
if ($('form[data-id=""]', add)) $('.mc-found-slot', add).innerHTML = found();
|
|
180
|
+
else add.innerHTML = `<div class="mc-found-slot">${found()}</div><h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${datalist()}</datalist>`;
|
|
181
|
+
} else add.innerHTML = `<div class="mc-found-slot">${found()}</div><button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${datalist()}</datalist>`;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const menu = (m, anchor) => {
|
|
185
|
+
$('.menu.machine')?.close();
|
|
186
|
+
const r = anchor.getBoundingClientRect();
|
|
187
|
+
const box = el(`<div class="menu machine" style="top:${r.bottom + 4}px;left:${Math.max(8, r.right - 230)}px">
|
|
188
|
+
<button data-m="edit">${icon('pen')}Edit…</button>
|
|
189
|
+
<button data-m="projects">${icon('folder')}Projects on it…</button>
|
|
190
|
+
${m.state === 'online' ? `<button data-m="restart">${icon('rerun')}Restart wisp there</button><button data-m="stop" class="danger">${icon('stop')}Stop wisp there</button>` : ''}
|
|
191
|
+
<button data-m="remove" class="danger">${icon('trash')}Remove machine</button>
|
|
192
|
+
</div>`);
|
|
193
|
+
box.onclick = (ev) => {
|
|
194
|
+
const a = ev.target.closest('button')?.dataset.m;
|
|
195
|
+
shut();
|
|
196
|
+
if (a) act(a, m);
|
|
197
|
+
};
|
|
198
|
+
const shut = popover(box);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const act = async (a, m) => {
|
|
202
|
+
try {
|
|
203
|
+
if (a === 'connect') await api(`/machines/${m.id}/connect`, { method: 'POST', body: {} });
|
|
204
|
+
if (a === 'disconnect') await api(`/machines/${m.id}/disconnect`, { method: 'POST', body: {} });
|
|
205
|
+
if (a === 'projects') openProjects({ machine: m.id, host: sub, focusAdd: true });
|
|
206
|
+
if (a === 'edit') {
|
|
207
|
+
editing = m.id;
|
|
208
|
+
paint();
|
|
209
|
+
$(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
|
|
210
|
+
}
|
|
211
|
+
if (a === 'restart' || a === 'update') {
|
|
212
|
+
const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
|
|
213
|
+
if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
|
|
214
|
+
await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
|
|
215
|
+
}
|
|
216
|
+
if (a === 'stop') {
|
|
217
|
+
if (!confirm(`Stop wisp on ${m.name}? ${m.working ? `${m.working} working agent${m.working === 1 ? '' : 's'} stop too. ` : ''}Its sessions stay there for next time.`)) return;
|
|
218
|
+
await api(`/machines/${m.id}/stop`, { method: 'POST', body: {} });
|
|
219
|
+
toast(`Stopped wisp on <b>${esc(m.name)}</b>`);
|
|
220
|
+
}
|
|
221
|
+
if (a === 'remove') {
|
|
222
|
+
if (!confirm(`Remove ${m.name} from wisp? Its projects leave the launcher. Nothing on the machine is touched, and wisp keeps running there until you stop it.`)) return;
|
|
223
|
+
await api(`/machines/${m.id}`, { method: 'DELETE' });
|
|
224
|
+
await loadConfig();
|
|
225
|
+
await loadMachines();
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
toastError(err);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
node.addEventListener('click', async (e) => {
|
|
233
|
+
const b = e.target.closest('[data-a]');
|
|
234
|
+
if (!b || !body.contains(b)) return;
|
|
235
|
+
const a = b.dataset.a;
|
|
236
|
+
const m = machineById(b.closest('.mc')?.dataset.id);
|
|
237
|
+
if (a === 'copy') {
|
|
238
|
+
copy(b.dataset.cmd);
|
|
239
|
+
toast('Copied. Run it in a terminal, then connect again.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (a === 'new') {
|
|
243
|
+
editing = '';
|
|
244
|
+
paint();
|
|
245
|
+
$('.mc-add input[name="target"]', node)?.focus();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (a === 'pick') {
|
|
249
|
+
// Fill in the form; connecting still waits for the button.
|
|
250
|
+
editing = '';
|
|
251
|
+
paint();
|
|
252
|
+
const f = $('.mc-add form[data-id=""]', node);
|
|
253
|
+
f.target.value = b.dataset.target;
|
|
254
|
+
f.name.value = b.dataset.name;
|
|
255
|
+
$('button[type="submit"]', f).focus();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (a === 'cancel') {
|
|
259
|
+
editing = null;
|
|
260
|
+
paint();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (a === 'menu' && m) {
|
|
264
|
+
menu(m, b);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (m) act(a, m);
|
|
268
|
+
});
|
|
269
|
+
// The name follows the host until you type one.
|
|
270
|
+
node.addEventListener('input', (e) => {
|
|
271
|
+
if (e.target.name !== 'target') return;
|
|
272
|
+
const name = $('input[name="name"]', e.target.form);
|
|
273
|
+
const h = hosts.find((x) => x.target === e.target.value.trim());
|
|
274
|
+
name.placeholder = (h?.name || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
|
|
275
|
+
});
|
|
276
|
+
node.addEventListener('submit', async (e) => {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
const f = e.target;
|
|
279
|
+
const id = f.dataset.id;
|
|
280
|
+
const v = Object.fromEntries(new FormData(f));
|
|
281
|
+
const payload = { target: v.target.trim(), name: v.name.trim(), port: Number(v.port) || 4477, command: v.command.trim(), autoConnect: !!v.autoConnect };
|
|
282
|
+
const btn = $('button[type="submit"]', f);
|
|
283
|
+
btn.disabled = true;
|
|
284
|
+
try {
|
|
285
|
+
if (id) await api(`/machines/${id}`, { method: 'PATCH', body: payload });
|
|
286
|
+
else await api('/machines', { method: 'POST', body: payload });
|
|
287
|
+
editing = null;
|
|
288
|
+
await loadMachines();
|
|
289
|
+
paint();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
toastError(err);
|
|
292
|
+
} finally {
|
|
293
|
+
btn.disabled = false;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
bus.on(
|
|
297
|
+
'machines',
|
|
298
|
+
() => {
|
|
299
|
+
// Don't clobber a half-typed form: repaint the cards only.
|
|
300
|
+
const typing = node.contains(document.activeElement) && document.activeElement.matches('input, select');
|
|
301
|
+
if (typing && editing !== null && editing !== '') return;
|
|
302
|
+
if (typing) $('.mc-list', node).innerHTML = S.machines.map(card).join('');
|
|
303
|
+
else paint();
|
|
304
|
+
},
|
|
305
|
+
{ node },
|
|
306
|
+
);
|
|
307
|
+
paint();
|
|
308
|
+
if (focusAdd && S.machines.length) {
|
|
309
|
+
editing = '';
|
|
310
|
+
paint();
|
|
311
|
+
}
|
|
312
|
+
if (focusAdd || !S.machines.length) setTimeout(() => $('.mc-add input[name="target"]', node)?.focus(), 60);
|
|
313
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Memory: what the background retros learned, as preferences and lessons per project.
|
|
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 } from '../core/format.js';
|
|
8
|
+
import { launch, S } from '../core/state.js';
|
|
9
|
+
import { present } from '../ui/overlay.js';
|
|
10
|
+
import { toastError, tryApi } from '../ui/toast.js';
|
|
11
|
+
import { reveal } from './feed.js';
|
|
12
|
+
import { loadConfig } from './live.js';
|
|
13
|
+
import { projectById, projectName } from './places.js';
|
|
14
|
+
import { agentName } from './runners.js';
|
|
15
|
+
|
|
16
|
+
const MEM_KINDS = [
|
|
17
|
+
{ kind: 'preferences', label: 'Preferences', one: 'preference', icon: 'spark', hint: 'How you like agents to work', empty: 'Corrections and “always / never” asks from your sessions land here.' },
|
|
18
|
+
{ kind: 'lessons', label: 'Lessons', one: 'lesson', icon: 'check', hint: 'A mistake an agent made, and the way that worked', empty: 'When an agent hits a wall and finds the fix, the fix lands here.' },
|
|
19
|
+
];
|
|
20
|
+
const MEM_CAP = 10;
|
|
21
|
+
const memCount = (x) => x.preferences.length + x.lessons.length;
|
|
22
|
+
|
|
23
|
+
export async function setLearning(on) {
|
|
24
|
+
await api('/config', { method: 'PATCH', body: { defaults: { learn: on ? 'on' : 'off' } } });
|
|
25
|
+
await loadConfig();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function memorySummary(data) {
|
|
29
|
+
if (!data) return '';
|
|
30
|
+
if (!data.enabled) return 'Off · nothing learned or applied';
|
|
31
|
+
const n = memCount(data.global) + data.projects.reduce((a, p) => a + memCount(p), 0);
|
|
32
|
+
const last = data.history[0];
|
|
33
|
+
if (data.running) return `Retro running · ${n} remembered`;
|
|
34
|
+
return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openMemory({ tab = 'global', host } = {}) {
|
|
38
|
+
const node = el(`<div class="modal memory-modal">
|
|
39
|
+
<header><h2>Memory</h2><span class="mem-live"></span><button class="switch" id="memOn" role="switch" title="Learn from sessions"></button><button class="icon-btn close">${icon('x')}</button></header>
|
|
40
|
+
<p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
|
|
41
|
+
<div class="mem-tabs" role="tablist"></div>
|
|
42
|
+
<div class="mem-body"></div>
|
|
43
|
+
</div>`);
|
|
44
|
+
const close = present(node, host);
|
|
45
|
+
let data;
|
|
46
|
+
let editing = null; // { scope, kind, index } (index -1 = adding)
|
|
47
|
+
|
|
48
|
+
const render = async () => {
|
|
49
|
+
data = (await api('/preferences').catch(() => null)) || data;
|
|
50
|
+
if (!data || !node.isConnected) return;
|
|
51
|
+
// Projects with something learned, plus the one picked in the launcher so you can add to it.
|
|
52
|
+
const current = projectById(launch.project);
|
|
53
|
+
const projects = data.projects.filter((p) => memCount(p) || p.path === current?.path);
|
|
54
|
+
if (current && !projects.some((p) => p.path === current.path)) projects.push({ path: current.path, name: current.name, preferences: [], lessons: [] });
|
|
55
|
+
const scopes = [{ scope: 'global', name: 'Everywhere', ...data.global }, ...projects.map((p) => ({ scope: p.path, ...p }))];
|
|
56
|
+
if (tab !== 'retros' && !scopes.some((x) => x.scope === tab)) tab = 'global';
|
|
57
|
+
$('#memOn', node).setAttribute('aria-checked', data.enabled);
|
|
58
|
+
$('.mem-live', node).innerHTML = data.running ? `<span class="orb live"></span>Retro running` : data.enabled ? '' : 'Off';
|
|
59
|
+
$('.mem-tabs', node).innerHTML =
|
|
60
|
+
scopes.map((x) => `<button role="tab" data-tab="${esc(x.scope)}" class="${tab === x.scope ? 'on' : ''}" title="${esc(x.scope === 'global' ? 'Applies in every project' : x.scope.replace(S.config.home, '~'))}">${esc(x.name)}<span>${memCount(x)}</span></button>`).join('') +
|
|
61
|
+
`<i></i><button role="tab" data-tab="retros" class="${tab === 'retros' ? 'on' : ''}">Retros<span>${data.history.length}</span></button>`;
|
|
62
|
+
const body = $('.mem-body', node);
|
|
63
|
+
body.classList.toggle('off', !data.enabled);
|
|
64
|
+
const banner = data.enabled ? '' : `<div class="mem-off">${icon('info')}<span>Memory is off: no retros run, and nothing below reaches your agents.</span><button class="ghost xs" data-act="on">Turn on</button></div>`;
|
|
65
|
+
body.innerHTML = banner + (tab === 'retros' ? retrosView() : scopeView(scopes.find((x) => x.scope === tab)));
|
|
66
|
+
const input = $('.mem-edit input', body);
|
|
67
|
+
if (input) input.focus(), input.setSelectionRange(input.value.length, input.value.length);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Which retro added an item, if it's still in the log.
|
|
71
|
+
const origin = (scope, kind, text) => data.history.find((h) => h.added?.some((a) => a.text === text && a.kind === kind && (a.scope === 'global') === (scope === 'global')));
|
|
72
|
+
|
|
73
|
+
const scopeView = (x) => {
|
|
74
|
+
const chars = [...(x.scope === 'global' ? [] : [...data.global.preferences, ...data.global.lessons]), ...x.preferences, ...x.lessons].reduce((a, t) => a + t.length + 3, 0);
|
|
75
|
+
const budget = x.scope === 'global' ? `Added to every prompt · about ${Math.ceil(chars / 4)} tokens` : `Added to prompts in ${esc(x.name)}, with Everywhere · about ${Math.ceil(chars / 4)} tokens`;
|
|
76
|
+
return `<p class="mem-budget muted xs">${budget}</p>` + MEM_KINDS.map((k) => {
|
|
77
|
+
const items = x[k.kind];
|
|
78
|
+
const row = (t, i) => {
|
|
79
|
+
if (editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === i) return editRow(t, k);
|
|
80
|
+
const o = origin(x.scope, k.kind, t);
|
|
81
|
+
return `<div class="mem-item" data-i="${i}"><span class="mem-ico">${icon(k.icon)}</span><div class="mem-main"><p>${esc(t)}</p>${o ? `<span class="mem-src">${o.sessionId && S.sessions.has(o.sessionId) ? `<a href="#" data-session="${esc(o.sessionId)}">${esc(o.title || 'session')}</a>` : esc(o.title || 'a session')} · ${ago(o.at)}</span>` : ''}</div>
|
|
82
|
+
<span class="mem-acts"><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="forget" title="Forget">${icon('trash')}</button></span></div>`;
|
|
83
|
+
};
|
|
84
|
+
const adding = editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === -1;
|
|
85
|
+
return `<section class="mem-sec" data-scope="${esc(x.scope)}" data-kind="${k.kind}">
|
|
86
|
+
<h4>${k.label}<span class="muted xs">${k.hint}</span><em class="${items.length >= MEM_CAP ? 'full' : ''}" title="At most ${MEM_CAP}; a retro drops the least useful when full">${items.length}/${MEM_CAP}</em></h4>
|
|
87
|
+
${items.map(row).join('') || (adding ? '' : `<p class="mem-empty">${k.empty}</p>`)}
|
|
88
|
+
${adding ? editRow('', k) : `<button class="mem-add" data-act="add">${icon('plus')}Add a ${k.one}</button>`}
|
|
89
|
+
</section>`;
|
|
90
|
+
}).join('');
|
|
91
|
+
};
|
|
92
|
+
const editRow = (t, k) => `<div class="mem-item mem-edit"><span class="mem-ico">${icon(k.icon)}</span><input value="${esc(t)}" maxlength="180" spellcheck="false" placeholder="One short sentence, e.g. “Run tests with node --test”"><span class="muted xs">↵ save · esc cancel</span></div>`;
|
|
93
|
+
|
|
94
|
+
const retrosView = () => {
|
|
95
|
+
const live = data.running ? `<div class="tl running"><span class="tl-dot"><span class="orb live"></span></span><div class="tl-main"><div class="tl-head"><b>${esc(data.running.title || 'session')}</b><span class="muted xs">now</span></div><p class="muted xs">Reading ${data.running.turns} turn${data.running.turns === 1 ? '' : 's'} with ${esc(agentName(data.running.agent))}…</p></div></div>` : '';
|
|
96
|
+
const where = (a) => `<span class="tl-tag">${a.scope === 'global' ? 'everywhere' : 'project'} · ${a.kind === 'lessons' ? 'lesson' : 'preference'}</span>`;
|
|
97
|
+
const entries = data.history.map((h) => {
|
|
98
|
+
const status = h.status || 'learned';
|
|
99
|
+
const lines =
|
|
100
|
+
status === 'failed' ? `<p class="tl-err">${esc(h.error || 'The retro run failed.')}</p>`
|
|
101
|
+
: status === 'nothing' ? '<p class="muted xs">Nothing new worth remembering.</p>'
|
|
102
|
+
: [...h.added.map((a) => `<p class="tl-add">${esc(a.text ?? a)} ${a.kind ? where(a) : ''}</p>`), ...h.removed.map((a) => `<p class="tl-del"><s>${esc(a.text ?? a)}</s> ${a.kind ? where(a) : ''}</p>`)].join('');
|
|
103
|
+
const title = S.sessions.has(h.sessionId) ? `<a href="#" data-session="${esc(h.sessionId)}">${esc(h.title || 'session')}</a>` : esc(h.title || 'session');
|
|
104
|
+
return `<div class="tl ${status}"><span class="tl-dot"></span><div class="tl-main"><div class="tl-head"><b>${title}</b>${h.project ? `<span class="muted xs">${esc(projectName(h.project))}</span>` : ''}<span class="muted xs">${ago(h.at)}</span></div>${lines}</div></div>`;
|
|
105
|
+
});
|
|
106
|
+
return live + entries.join('') || `<p class="mem-empty">No retros yet. One runs 90 s after a session finishes, or as soon as you mark it done or archive it.</p>`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const save = async (scope, kind, items) => {
|
|
110
|
+
await api('/preferences', { method: 'PUT', body: { scope, kind, items } }).catch(toastError);
|
|
111
|
+
editing = null;
|
|
112
|
+
await render();
|
|
113
|
+
};
|
|
114
|
+
const commit = (input) => {
|
|
115
|
+
if (!editing) return;
|
|
116
|
+
const { scope, kind, index } = editing;
|
|
117
|
+
editing = null; // Enter then blur must not save twice
|
|
118
|
+
const list = ((scope === 'global' ? data.global : data.projects.find((p) => p.path === scope)) || { [kind]: [] })[kind].slice();
|
|
119
|
+
const v = input.value.trim();
|
|
120
|
+
if (index === -1) v ? list.push(v) : null;
|
|
121
|
+
else v ? (list[index] = v) : list.splice(index, 1);
|
|
122
|
+
save(scope, kind, list);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
node.addEventListener('click', async (e) => {
|
|
126
|
+
const t = e.target.closest('[data-tab],[data-act],[data-session]');
|
|
127
|
+
if (!t) return;
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
if (t.dataset.tab) return (tab = t.dataset.tab), (editing = null), render();
|
|
130
|
+
if (t.dataset.session) return close(), reveal(t.dataset.session);
|
|
131
|
+
const act = t.dataset.act;
|
|
132
|
+
if (act === 'on') return tryApi(() => setLearning(true)).then(render);
|
|
133
|
+
const sec = t.closest('.mem-sec');
|
|
134
|
+
const { scope, kind } = sec.dataset;
|
|
135
|
+
const index = Number(t.closest('.mem-item')?.dataset.i ?? -1);
|
|
136
|
+
if (act === 'add' || act === 'edit') return (editing = { scope, kind, index }), render();
|
|
137
|
+
if (act === 'forget') {
|
|
138
|
+
const list = (scope === 'global' ? data.global : data.projects.find((p) => p.path === scope))[kind].filter((_, i) => i !== index);
|
|
139
|
+
return save(scope, kind, list);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
node.addEventListener('keydown', (e) => {
|
|
143
|
+
if (!e.target.matches('.mem-edit input')) return;
|
|
144
|
+
if (e.key === 'Enter') e.preventDefault(), commit(e.target);
|
|
145
|
+
if (e.key === 'Escape') e.preventDefault(), e.stopPropagation(), (editing = null), render();
|
|
146
|
+
});
|
|
147
|
+
node.addEventListener('focusout', (e) => e.target.matches('.mem-edit input') && editing && commit(e.target));
|
|
148
|
+
$('#memOn', node).onclick = async () => {
|
|
149
|
+
await tryApi(() => setLearning(!data?.enabled));
|
|
150
|
+
render();
|
|
151
|
+
};
|
|
152
|
+
bus.on('preferences', () => !editing && render(), { node });
|
|
153
|
+
render();
|
|
154
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Notifications when a task finishes or asks: a toast, and a desktop notification when the window
|
|
2
|
+
// isn't focused.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { icon } from '../core/dom.js';
|
|
6
|
+
import { S } from '../core/state.js';
|
|
7
|
+
import { toast } from '../ui/toast.js';
|
|
8
|
+
import { reveal } from './feed.js';
|
|
9
|
+
import { watching } from './scroll.js';
|
|
10
|
+
|
|
11
|
+
// On unless turned off in settings, and only if the browser lets us.
|
|
12
|
+
export function notifyOn() {
|
|
13
|
+
return S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'granted';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function askNotify() {
|
|
17
|
+
if (S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function notifyFinished(s) {
|
|
21
|
+
const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
|
|
22
|
+
if (viewing) return;
|
|
23
|
+
let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
24
|
+
// A task run in several places (or compared) says so once, when its last run finishes.
|
|
25
|
+
const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
|
|
26
|
+
if (runs.length > 1 && s.state !== 'waiting') {
|
|
27
|
+
if (runs.some((x) => x.id !== s.id && x.state === 'running')) return;
|
|
28
|
+
const places = new Set(runs.map((x) => x.machine?.id || '')).size;
|
|
29
|
+
verb = places > 1 ? `is ready on ${places} machines` : `is ready (${runs.length} runs)`;
|
|
30
|
+
}
|
|
31
|
+
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
32
|
+
if (notifyOn() && !document.hasFocus()) {
|
|
33
|
+
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
|
|
34
|
+
note.onclick = () => (window.focus(), reveal(s.id));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Phone: questions and finished tasks over ntfy or Telegram, answered from there.
|
|
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 { S } from '../core/state.js';
|
|
8
|
+
import { present } from '../ui/overlay.js';
|
|
9
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
10
|
+
import { placeName, recentFirst } from './places.js';
|
|
11
|
+
|
|
12
|
+
export function phoneSummary(r) {
|
|
13
|
+
if (!r) return '';
|
|
14
|
+
const on = [r.ntfy.enabled && r.ntfy.topic && 'ntfy', r.telegram.enabled && r.telegram.linked && 'Telegram'].filter(Boolean);
|
|
15
|
+
return on.length ? `${on.join(' + ')} · ${r.events.map((e) => (e === 'waiting' ? 'questions' : 'finished')).join(' & ')}` : 'Off';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function openPhone({ host } = {}) {
|
|
19
|
+
const node = el(`<div class="modal phone-modal">
|
|
20
|
+
<header><h2>Phone</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
21
|
+
<p class="mem-lede">Get questions and finished tasks on your phone, and answer from there. wisp polls both services, so nothing needs to reach this machine. They receive titles, summaries and questions (with their context), never code or diffs.</p>
|
|
22
|
+
<div class="ph-body"></div>
|
|
23
|
+
</div>`);
|
|
24
|
+
const close = present(node, host);
|
|
25
|
+
let r;
|
|
26
|
+
const put = async (patch) => {
|
|
27
|
+
try {
|
|
28
|
+
r = await api('/remote', { method: 'PUT', body: patch });
|
|
29
|
+
paint();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
toastError(err);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const status = (st) => (!st ? '' : st.ok ? `<span class="ph-ok">${icon('check')}Working</span>` : `<span class="ph-bad">${icon('warn')}${esc(st.error || 'Failed')}</span>`);
|
|
35
|
+
const paint = () => {
|
|
36
|
+
const tg = r.telegram;
|
|
37
|
+
const projects = S.config.projects || [];
|
|
38
|
+
const lastUsed = [...projects].sort(recentFirst)[0];
|
|
39
|
+
$('.ph-body', node).innerHTML = `
|
|
40
|
+
<div class="settings-modal ph-sec">
|
|
41
|
+
<div class="setting"><span>Send me</span>
|
|
42
|
+
<label class="check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
|
|
43
|
+
<label class="check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
|
|
44
|
+
</div>
|
|
45
|
+
<section class="ph-sec">
|
|
46
|
+
<h4>ntfy <span class="muted xs">· tap a button on the notification to answer</span>${status(r.status.ntfy)}<button class="switch" data-sw="ntfy" role="switch" aria-checked="${r.ntfy.enabled}"></button></h4>
|
|
47
|
+
<div class="settings-modal">
|
|
48
|
+
<label><span>Topic</span><input class="input mono" data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
|
|
49
|
+
<label><span>Server</span><input class="input mono" data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
|
|
50
|
+
<label><span>Access token</span><input class="input mono" data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
|
|
51
|
+
</div>
|
|
52
|
+
<p class="hint">Install the ntfy app and subscribe to the topic. Anyone who knows the topic name can read it, so keep it long and random; answers are signed, so nobody else can send them.</p>
|
|
53
|
+
</section>
|
|
54
|
+
<section class="ph-sec">
|
|
55
|
+
<h4>Telegram <span class="muted xs">· buttons, or reply to a message in your own words</span>${status(r.status.telegram)}<button class="switch" data-sw="telegram" role="switch" aria-checked="${tg.enabled}"></button></h4>
|
|
56
|
+
<div class="settings-modal">
|
|
57
|
+
<label><span>Bot token</span><input class="input mono" data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
|
|
58
|
+
</div>
|
|
59
|
+
<p class="hint">${
|
|
60
|
+
tg.linked
|
|
61
|
+
? `${icon('check')} Linked to <b>${esc(tg.chatName || 'your chat')}</b>. <button class="link xs" data-act="unlink">Unlink</button>`
|
|
62
|
+
: tg.token && tg.enabled
|
|
63
|
+
? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
|
|
64
|
+
: 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
|
|
65
|
+
}</p>
|
|
66
|
+
${
|
|
67
|
+
tg.linked
|
|
68
|
+
? `<div class="ph-start">
|
|
69
|
+
<div class="ph-row"><span class="grow">Start tasks from Telegram</span><button class="switch" data-sw="tgStart" role="switch" aria-checked="${tg.allowStart}"></button></div>
|
|
70
|
+
${
|
|
71
|
+
tg.allowStart
|
|
72
|
+
? `<div class="settings-modal"><label><span>Default project</span><select class="input" data-tg="project">
|
|
73
|
+
<option value="">Last used${lastUsed ? ` (${esc(lastUsed.name)})` : ''}</option>
|
|
74
|
+
${projects.map((p) => `<option value="${esc(p.id)}" ${p.id === tg.project ? 'selected' : ''}>${esc(p.name)}${p.machine ? ` · ${esc(placeName(p.machine))}` : ''}</option>`).join('')}
|
|
75
|
+
</select></label></div>
|
|
76
|
+
<p class="hint">Send <code>/new fix the flaky login test</code> to start a task in the default project, or <code>/new @${esc(projects[0]?.name || 'project')} …</code> to pick one; <code>/projects</code> lists them. Tasks use the launcher's harness, model and mode. Only your linked chat can start them.</p>`
|
|
77
|
+
: '<p class="hint">Off: the bot only answers and follows up on tasks you started here.</p>'
|
|
78
|
+
}
|
|
79
|
+
</div>`
|
|
80
|
+
: ''
|
|
81
|
+
}
|
|
82
|
+
</section>
|
|
83
|
+
<div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
|
|
84
|
+
};
|
|
85
|
+
node.addEventListener('change', (e) => {
|
|
86
|
+
const t = e.target;
|
|
87
|
+
if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
|
|
88
|
+
if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
|
|
89
|
+
if (t.dataset.tg === 'token') put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
|
|
90
|
+
if (t.dataset.tg === 'project') put({ telegram: { project: t.value } });
|
|
91
|
+
});
|
|
92
|
+
node.addEventListener('click', async (e) => {
|
|
93
|
+
const sw = e.target.closest('[data-sw]')?.dataset.sw;
|
|
94
|
+
if (sw === 'ntfy') {
|
|
95
|
+
// A fresh random topic the first time, so it isn't guessable.
|
|
96
|
+
const topic = r.ntfy.topic || `wisp-${crypto.getRandomValues(new Uint32Array(2)).join('').slice(0, 14)}`;
|
|
97
|
+
return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
|
|
98
|
+
}
|
|
99
|
+
if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
|
|
100
|
+
if (sw === 'tgStart') return put({ telegram: { allowStart: !r.telegram.allowStart } });
|
|
101
|
+
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
102
|
+
if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
|
|
103
|
+
if (act === 'test') {
|
|
104
|
+
try {
|
|
105
|
+
r = await api('/remote/test', { method: 'POST', body: {} });
|
|
106
|
+
paint();
|
|
107
|
+
toast('Test sent');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
toastError(err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
bus.on(
|
|
114
|
+
'remote',
|
|
115
|
+
async () => {
|
|
116
|
+
if (node.contains(document.activeElement) && document.activeElement.matches('input')) return; // don't clobber typing
|
|
117
|
+
r = await api('/remote').catch(() => r);
|
|
118
|
+
if (r && node.isConnected) paint();
|
|
119
|
+
},
|
|
120
|
+
{ node },
|
|
121
|
+
);
|
|
122
|
+
r = await api('/remote').catch((err) => (toastError(err), null));
|
|
123
|
+
if (r) paint();
|
|
124
|
+
}
|