@bill10/agent-007 0.6.2
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/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Voice input — dictate into the active terminal via the Web Speech API.
|
|
2
|
+
// Finalized speech is sent as pty-input (exactly like typing); nothing is
|
|
3
|
+
// auto-submitted — the user still presses Enter to send the prompt. Note that
|
|
4
|
+
// transcripts are keystrokes: a raw-mode program at the prompt (a pager, a
|
|
5
|
+
// y/n confirmation) reacts to them like typing, so the mic is deliberately
|
|
6
|
+
// bounded — it stops on silence, on session switch or end, on a hidden tab,
|
|
7
|
+
// and at an absolute session cap.
|
|
8
|
+
import { agents, activeSessionId, canControlAgent } from './state.js';
|
|
9
|
+
import { send } from './ws.js';
|
|
10
|
+
|
|
11
|
+
const LISTENING_LABEL = 'Listening…';
|
|
12
|
+
const FLASH_HIDE_MS = 4000;
|
|
13
|
+
const RESTART_DELAY_MS = 250;
|
|
14
|
+
// Browsers end a recognition session after a few seconds of silence and we
|
|
15
|
+
// restart it. 8 consecutive sessions with no *delivered* speech ≈ a minute of
|
|
16
|
+
// silence (or ~2s of abort ping-pong with another tab using the mic) — stop
|
|
17
|
+
// instead of keeping a hot mic forever. Ambient noise can still produce
|
|
18
|
+
// results, so an absolute wall-clock cap backs this up.
|
|
19
|
+
const SILENT_RESTART_LIMIT = 8;
|
|
20
|
+
const SESSION_MAX_MS = 5 * 60 * 1000;
|
|
21
|
+
// The indicator pill shows the tail of the interim transcript — the words
|
|
22
|
+
// being spoken now — not the head.
|
|
23
|
+
const INTERIM_TAIL_CHARS = 80;
|
|
24
|
+
|
|
25
|
+
// User intent: mic toggled on. The browser stops recognition on its own after
|
|
26
|
+
// silence, so onend restarts it while this stays true.
|
|
27
|
+
let listening = false;
|
|
28
|
+
let recognition = null;
|
|
29
|
+
let silentRestarts = 0;
|
|
30
|
+
let sessionStart = 0;
|
|
31
|
+
let flashTimer = null;
|
|
32
|
+
// Bumped on every toggle-on; async permission callbacks compare against it so
|
|
33
|
+
// a stop → quick re-toggle can't leave two recognition sessions running.
|
|
34
|
+
let voiceGen = 0;
|
|
35
|
+
// True after getUserMedia has succeeded once: later toggles skip the prime
|
|
36
|
+
// (each acquire/release cycle costs 100-500ms of dead latency before speech).
|
|
37
|
+
// Invalidated when recognition hits a permission/hardware error so a revoked
|
|
38
|
+
// mic re-enters the honest getUserMedia-first flow instead of fast-pathing
|
|
39
|
+
// into recording signals that instantly die.
|
|
40
|
+
let micPrimed = false;
|
|
41
|
+
// True once beginListening() turned the recording signals on — gates the
|
|
42
|
+
// "Voice input stopped" announcement so a cancelled permission phase doesn't
|
|
43
|
+
// announce a stop for a session that never started.
|
|
44
|
+
let signalsOn = false;
|
|
45
|
+
|
|
46
|
+
function recognitionCtor() {
|
|
47
|
+
return window.SpeechRecognition || window.webkitSpeechRecognition || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Collapse whitespace and separate consecutive utterances with a space.
|
|
51
|
+
// Exported for tests.
|
|
52
|
+
export function normalizeTranscript(text) {
|
|
53
|
+
const t = String(text).replace(/\s+/g, ' ').trim();
|
|
54
|
+
return t ? t + ' ' : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Pure recognition-event logic, exported for tests. The Speech API event
|
|
58
|
+
// handlers below stay thin shells over these so the mic's safety bookkeeping
|
|
59
|
+
// (silence budget, session cap, error mapping) is unit-testable in node. ---
|
|
60
|
+
|
|
61
|
+
// Split one recognition result batch into normalized finalized chunks and the
|
|
62
|
+
// concatenated interim text.
|
|
63
|
+
export function collectResults(results, startIndex) {
|
|
64
|
+
const finals = [];
|
|
65
|
+
let interim = '';
|
|
66
|
+
for (let i = startIndex; i < results.length; i++) {
|
|
67
|
+
const result = results[i];
|
|
68
|
+
if (result.isFinal) {
|
|
69
|
+
const text = normalizeTranscript(result[0].transcript);
|
|
70
|
+
if (text) finals.push(text);
|
|
71
|
+
} else {
|
|
72
|
+
interim += result[0].transcript;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { finals, interim };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The indicator pill shows the words being spoken NOW — the tail, not the head.
|
|
79
|
+
export function interimTail(interim) {
|
|
80
|
+
return interim.length > INTERIM_TAIL_CHARS ? '…' + interim.slice(-INTERIM_TAIL_CHARS) : interim;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Map a SpeechRecognition error code to a user-facing message, or null for
|
|
84
|
+
// codes that are part of normal operation (silence timeouts, our own abort()).
|
|
85
|
+
export function recognitionErrorMessage(code) {
|
|
86
|
+
if (code === 'no-speech' || code === 'aborted') return null;
|
|
87
|
+
if (code === 'not-allowed' || code === 'service-not-allowed') {
|
|
88
|
+
return 'Microphone access denied — allow it in your browser settings';
|
|
89
|
+
}
|
|
90
|
+
if (code === 'audio-capture') return 'No microphone found';
|
|
91
|
+
return `Voice input error: ${code}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Decide what a recognition end means: give up after the silence budget is
|
|
95
|
+
// spent ('pause'), enforce the absolute session cap ('expire'), else 'restart'.
|
|
96
|
+
export function onEndAction(restarts, elapsedMs) {
|
|
97
|
+
if (restarts >= SILENT_RESTART_LIMIT) return 'pause';
|
|
98
|
+
if (elapsedMs > SESSION_MAX_MS) return 'expire';
|
|
99
|
+
return 'restart';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Map a getUserMedia rejection name to actionable guidance. Only permission
|
|
103
|
+
// errors should point at browser settings — a mic held by another app or
|
|
104
|
+
// missing hardware needs different advice.
|
|
105
|
+
export function mediaErrorMessage(name) {
|
|
106
|
+
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'No microphone found';
|
|
107
|
+
if (name === 'NotReadableError' || name === 'TrackStartError') return 'Microphone is in use by another app';
|
|
108
|
+
if (name === 'AbortError') return 'Microphone could not start — try again';
|
|
109
|
+
if (name === 'SecurityError') return 'Microphone blocked by browser or system policy';
|
|
110
|
+
return 'Microphone access denied — allow it in your browser settings, then click the mic again';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function micBtn() { return document.getElementById('btn-voice'); }
|
|
114
|
+
|
|
115
|
+
// Screen-reader announcements go to an always-rendered visually-hidden live
|
|
116
|
+
// region — the visual pill toggles display:none, which most screen readers
|
|
117
|
+
// won't announce, and per-interim rewrites would be announcement spam anyway.
|
|
118
|
+
// Only discrete transitions (started / stopped / errors) are announced.
|
|
119
|
+
function announce(text) {
|
|
120
|
+
const el = document.getElementById('voice-status');
|
|
121
|
+
if (el) el.textContent = text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Indicator pill: cached element + last-rendered state so per-interim-result
|
|
125
|
+
// updates skip redundant style/layout work. kind: 'live' shows the recording
|
|
126
|
+
// dot; 'notice' and 'error' hide it (the mic is off — a pulsing red dot would
|
|
127
|
+
// be an inverted privacy signal).
|
|
128
|
+
let indicatorEl = null;
|
|
129
|
+
let indicatorLast = null;
|
|
130
|
+
|
|
131
|
+
function indicator() {
|
|
132
|
+
if (!indicatorEl) indicatorEl = document.getElementById('voice-indicator');
|
|
133
|
+
return indicatorEl;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function showIndicator(text, kind = 'live') {
|
|
137
|
+
const el = indicator();
|
|
138
|
+
if (!el) return;
|
|
139
|
+
const key = `${kind}:${text}`;
|
|
140
|
+
if (key === indicatorLast) return;
|
|
141
|
+
indicatorLast = key;
|
|
142
|
+
el.querySelector('.voice-indicator-text').textContent = text;
|
|
143
|
+
el.classList.toggle('error', kind === 'error');
|
|
144
|
+
el.classList.toggle('notice', kind === 'notice');
|
|
145
|
+
el.style.display = 'flex';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hideIndicator() {
|
|
149
|
+
const el = indicator();
|
|
150
|
+
if (!el) return;
|
|
151
|
+
indicatorLast = null;
|
|
152
|
+
el.style.display = 'none';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Flash a transient message in the pill, then hide it unless dictation runs.
|
|
156
|
+
function flashIndicator(text, kind) {
|
|
157
|
+
showIndicator(text, kind);
|
|
158
|
+
announce(text);
|
|
159
|
+
clearTimeout(flashTimer);
|
|
160
|
+
flashTimer = setTimeout(() => { if (!listening) hideIndicator(); }, FLASH_HIDE_MS);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function showError(text) { flashIndicator(text, 'error'); }
|
|
164
|
+
|
|
165
|
+
// Send one finalized transcript chunk to the active pty. Returns false when
|
|
166
|
+
// it cannot be delivered (no session, ended session, view-only, socket down)
|
|
167
|
+
// so the caller can stop instead of silently losing speech. The DISCONNECTED
|
|
168
|
+
// check mirrors the server's exited-session drop — without it a dead pty
|
|
169
|
+
// looks deliverable from the client. Exported for tests.
|
|
170
|
+
export function deliverToActivePty(text) {
|
|
171
|
+
if (!activeSessionId) return false;
|
|
172
|
+
const agent = agents.get(activeSessionId);
|
|
173
|
+
if (!agent || agent.state === 'DISCONNECTED' || !canControlAgent(agent)) return false;
|
|
174
|
+
return send({ type: 'pty-input', sessionId: activeSessionId, data: text });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sessionExpired() {
|
|
178
|
+
return Date.now() - sessionStart > SESSION_MAX_MS;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function startRecognition() {
|
|
182
|
+
const Ctor = recognitionCtor();
|
|
183
|
+
// Recreate the instance on every (re)start — reusing a stopped instance is
|
|
184
|
+
// flaky in Safari.
|
|
185
|
+
const rec = new Ctor();
|
|
186
|
+
recognition = rec;
|
|
187
|
+
rec.continuous = true;
|
|
188
|
+
rec.interimResults = true;
|
|
189
|
+
rec.lang = navigator.language || 'en-US';
|
|
190
|
+
|
|
191
|
+
rec.onresult = (event) => {
|
|
192
|
+
// stop() can flush one last result after the user toggled off — stop
|
|
193
|
+
// means stop, so drop it rather than resurrect the indicator (a stale
|
|
194
|
+
// "Listening…" overlay is an inverted privacy signal).
|
|
195
|
+
if (!listening || recognition !== rec) return;
|
|
196
|
+
if (sessionExpired()) {
|
|
197
|
+
stopVoice({ notice: 'Voice input stopped — session limit reached' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const { finals, interim } = collectResults(event.results, event.resultIndex);
|
|
201
|
+
for (const text of finals) {
|
|
202
|
+
if (!deliverToActivePty(text)) {
|
|
203
|
+
console.warn('[voice] transcript could not be delivered to the active pty');
|
|
204
|
+
stopVoice();
|
|
205
|
+
showError('Voice input stopped — transcript could not be delivered');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Only *delivered* speech refills the silence budget: interim-only
|
|
209
|
+
// ambient noise must not keep the mic hot forever.
|
|
210
|
+
silentRestarts = 0;
|
|
211
|
+
}
|
|
212
|
+
showIndicator(interimTail(interim) || LISTENING_LABEL, 'live');
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
rec.onerror = (event) => {
|
|
216
|
+
if (recognition !== rec) return;
|
|
217
|
+
console.warn('[voice] recognition error:', event.error);
|
|
218
|
+
// A permission or hardware failure means the earlier prime is stale
|
|
219
|
+
// (revoked in settings, mic unplugged) — drop it so the next toggle
|
|
220
|
+
// re-runs getUserMedia and can re-prompt.
|
|
221
|
+
if (event.error === 'not-allowed' || event.error === 'service-not-allowed' || event.error === 'audio-capture') {
|
|
222
|
+
micPrimed = false;
|
|
223
|
+
}
|
|
224
|
+
const message = recognitionErrorMessage(event.error);
|
|
225
|
+
if (!message) return;
|
|
226
|
+
stopVoice();
|
|
227
|
+
showError(message);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
rec.onend = () => {
|
|
231
|
+
// Only the current instance may restart — a superseded instance's end
|
|
232
|
+
// (stop → quick re-toggle) must not spawn a duelling recognizer.
|
|
233
|
+
if (!listening || recognition !== rec) return;
|
|
234
|
+
silentRestarts++;
|
|
235
|
+
const action = onEndAction(silentRestarts, Date.now() - sessionStart);
|
|
236
|
+
if (action === 'pause') {
|
|
237
|
+
stopVoice({ notice: 'Voice input paused — no speech detected' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (action === 'expire') {
|
|
241
|
+
stopVoice({ notice: 'Voice input stopped — session limit reached' });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
setTimeout(() => {
|
|
245
|
+
if (!listening || recognition !== rec) return;
|
|
246
|
+
try { startRecognition(); } catch {
|
|
247
|
+
stopVoice();
|
|
248
|
+
showError('Voice input stopped — could not restart the microphone');
|
|
249
|
+
}
|
|
250
|
+
}, RESTART_DELAY_MS);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
rec.start();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function toggleVoice() {
|
|
257
|
+
if (listening) { stopVoice(); refocusTerminal(); return; }
|
|
258
|
+
|
|
259
|
+
if (!recognitionCtor()) {
|
|
260
|
+
showError('Voice input is not supported in this browser (try Chrome, Edge, or Safari)');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!window.isSecureContext) {
|
|
264
|
+
showError('Voice input needs HTTPS or localhost — see docs/REMOTE.md (tailscale serve)');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const agent = activeSessionId ? agents.get(activeSessionId) : null;
|
|
268
|
+
if (!agent || agent.state === 'DISCONNECTED') {
|
|
269
|
+
showError('No running agent selected — open an agent first');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!canControlAgent(agent)) {
|
|
273
|
+
showError('This agent is view-only');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
278
|
+
showError('Could not start voice input — no microphone API in this browser');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
listening = true;
|
|
283
|
+
silentRestarts = 0;
|
|
284
|
+
sessionStart = Date.now();
|
|
285
|
+
const gen = ++voiceGen;
|
|
286
|
+
const btn = micBtn();
|
|
287
|
+
// aria-pressed reflects toggle intent now; the .listening recording pulse
|
|
288
|
+
// and the live dot wait until the mic is actually granted — signalling
|
|
289
|
+
// "recording" while a permission prompt sits open would be a lie.
|
|
290
|
+
if (btn) btn.setAttribute('aria-pressed', 'true');
|
|
291
|
+
|
|
292
|
+
if (micPrimed) {
|
|
293
|
+
beginListening();
|
|
294
|
+
refocusTerminal();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
showIndicator('Requesting microphone…', 'notice');
|
|
299
|
+
announce('Requesting microphone permission');
|
|
300
|
+
// Acquire the mic permission FIRST, then start recognition. Starting
|
|
301
|
+
// recognition while the browser's permission prompt is still open makes it
|
|
302
|
+
// error with not-allowed immediately — so by the time the user clicks
|
|
303
|
+
// "Allow", the mic is already off and their speech goes nowhere.
|
|
304
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
|
305
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
306
|
+
// The browser-level grant is real even if this toggle is no longer
|
|
307
|
+
// current — record it before the staleness guard so an Allow that lands
|
|
308
|
+
// after a stop isn't thrown away (and re-prompted for) next time.
|
|
309
|
+
micPrimed = true;
|
|
310
|
+
if (!listening || gen !== voiceGen) return;
|
|
311
|
+
// The user may have deliberated on the prompt for minutes — that dwell
|
|
312
|
+
// must not count against the dictation session cap.
|
|
313
|
+
sessionStart = Date.now();
|
|
314
|
+
beginListening();
|
|
315
|
+
}).catch((err) => {
|
|
316
|
+
console.warn('[voice] microphone unavailable:', err && err.name);
|
|
317
|
+
// Same staleness guard as .then: a denial arriving after the user already
|
|
318
|
+
// toggled off (or a session switch stopped voice) must not stomp that
|
|
319
|
+
// stop's notice with a stale error.
|
|
320
|
+
if (!listening || gen !== voiceGen) return;
|
|
321
|
+
stopVoice();
|
|
322
|
+
showError(mediaErrorMessage(err && err.name));
|
|
323
|
+
});
|
|
324
|
+
refocusTerminal();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The mic is granted and recognition is about to run — only now do the
|
|
328
|
+
// recording signals (pulse, live dot, announcement) turn on.
|
|
329
|
+
function beginListening() {
|
|
330
|
+
signalsOn = true;
|
|
331
|
+
const btn = micBtn();
|
|
332
|
+
if (btn) btn.classList.add('listening');
|
|
333
|
+
showIndicator(LISTENING_LABEL, 'live');
|
|
334
|
+
announce('Voice input started');
|
|
335
|
+
try {
|
|
336
|
+
startRecognition();
|
|
337
|
+
} catch {
|
|
338
|
+
stopVoice();
|
|
339
|
+
showError('Could not start voice input');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Land keyboard focus in the active terminal so the Enter that submits the
|
|
344
|
+
// dictated prompt reaches the pty — not the mic button or whatever element
|
|
345
|
+
// happened to hold focus when Cmd+D fired.
|
|
346
|
+
function refocusTerminal() {
|
|
347
|
+
const btn = micBtn();
|
|
348
|
+
if (btn) btn.blur();
|
|
349
|
+
const agent = activeSessionId ? agents.get(activeSessionId) : null;
|
|
350
|
+
if (agent && agent.term) agent.term.focus();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Stop dictation. opts.notice flashes an explanation when the stop wasn't
|
|
354
|
+
// user-initiated (session switch, agent ended, silence cap) — without it,
|
|
355
|
+
// in-flight speech would vanish with no cue.
|
|
356
|
+
export function stopVoice(opts = {}) {
|
|
357
|
+
const wasListening = listening;
|
|
358
|
+
const wasSignalling = signalsOn;
|
|
359
|
+
listening = false;
|
|
360
|
+
signalsOn = false;
|
|
361
|
+
const btn = micBtn();
|
|
362
|
+
if (btn) { btn.classList.remove('listening'); btn.setAttribute('aria-pressed', 'false'); }
|
|
363
|
+
hideIndicator();
|
|
364
|
+
if (recognition) {
|
|
365
|
+
const rec = recognition;
|
|
366
|
+
recognition = null;
|
|
367
|
+
// Detach before abort so nothing this instance flushes can restart the
|
|
368
|
+
// loop or repaint the indicator.
|
|
369
|
+
rec.onresult = null;
|
|
370
|
+
rec.onerror = null;
|
|
371
|
+
rec.onend = null;
|
|
372
|
+
try { rec.abort ? rec.abort() : rec.stop(); } catch {}
|
|
373
|
+
}
|
|
374
|
+
if (wasListening) {
|
|
375
|
+
if (opts.notice) flashIndicator(opts.notice, 'notice');
|
|
376
|
+
// Only announce a stop for a session whose start was announced — a
|
|
377
|
+
// cancelled permission phase never started.
|
|
378
|
+
else if (wasSignalling) announce('Voice input stopped');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function setupVoice() {
|
|
383
|
+
const btn = micBtn();
|
|
384
|
+
if (!btn) return;
|
|
385
|
+
// Leave the button visible even when unsupported: clicking explains why
|
|
386
|
+
// (missing API vs. insecure context) instead of silently hiding the feature.
|
|
387
|
+
// toggleVoice handles the terminal refocus itself (shared with Cmd+D).
|
|
388
|
+
btn.onclick = () => toggleVoice();
|
|
389
|
+
// A hidden tab can't see the pulsing mic — don't keep capturing audio.
|
|
390
|
+
document.addEventListener('visibilitychange', () => {
|
|
391
|
+
if (document.hidden && listening) stopVoice();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// WebSocket connection management
|
|
2
|
+
import { getToken, clearToken, showLogin, WS_UNAUTHORIZED } from './auth.js';
|
|
3
|
+
|
|
4
|
+
let ws = null;
|
|
5
|
+
let reconnectTimer = null;
|
|
6
|
+
let reconnectDelay = 1000;
|
|
7
|
+
let messageHandler = null;
|
|
8
|
+
let hasConnectedBefore = false;
|
|
9
|
+
|
|
10
|
+
export function connect(onMessage) {
|
|
11
|
+
messageHandler = onMessage;
|
|
12
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
13
|
+
// Browsers can't set handshake headers, so the token rides on the URL.
|
|
14
|
+
const token = getToken();
|
|
15
|
+
const query = token ? `/?token=${encodeURIComponent(token)}` : '';
|
|
16
|
+
ws = new WebSocket(`${protocol}//${location.host}${query}`);
|
|
17
|
+
|
|
18
|
+
ws.onopen = () => {
|
|
19
|
+
if (hasConnectedBefore) {
|
|
20
|
+
// Server restarted, reload to get clean state
|
|
21
|
+
location.reload();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
hasConnectedBefore = true;
|
|
25
|
+
document.getElementById('reconnecting').style.display = 'none';
|
|
26
|
+
reconnectDelay = 1000;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
ws.onclose = (event) => {
|
|
30
|
+
// 4401 = server requires auth and our token was missing/invalid. Don't
|
|
31
|
+
// reconnect-loop; clear the bad token and prompt for a new one.
|
|
32
|
+
if (event && event.code === WS_UNAUTHORIZED) {
|
|
33
|
+
clearToken();
|
|
34
|
+
showLogin();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
document.getElementById('reconnecting').style.display = 'block';
|
|
38
|
+
reconnectTimer = setTimeout(() => {
|
|
39
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
40
|
+
connect(messageHandler);
|
|
41
|
+
}, reconnectDelay);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
ws.onmessage = (event) => {
|
|
45
|
+
const msg = JSON.parse(event.data);
|
|
46
|
+
if (messageHandler) messageHandler(msg);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function send(msg) {
|
|
51
|
+
if (ws && ws.readyState === 1) {
|
|
52
|
+
ws.send(JSON.stringify(msg));
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|