@oratis/lisa 0.12.0 → 0.14.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/dist/agent.d.ts +2 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +20 -1
- package/dist/agent.js.map +1 -1
- package/dist/autonomy/state.d.ts +14 -0
- package/dist/autonomy/state.d.ts.map +1 -0
- package/dist/autonomy/state.js +54 -0
- package/dist/autonomy/state.js.map +1 -0
- package/dist/cli/mail.d.ts +2 -0
- package/dist/cli/mail.d.ts.map +1 -0
- package/dist/cli/mail.js +209 -0
- package/dist/cli/mail.js.map +1 -0
- package/dist/cli/pair.d.ts +1 -5
- package/dist/cli/pair.d.ts.map +1 -1
- package/dist/cli/pair.js +58 -20
- package/dist/cli/pair.js.map +1 -1
- package/dist/cli.js +15 -14
- package/dist/cli.js.map +1 -1
- package/dist/consent/store.d.ts +2 -2
- package/dist/consent/store.d.ts.map +1 -1
- package/dist/consent/store.js +3 -2
- package/dist/consent/store.js.map +1 -1
- package/dist/edition.d.ts +24 -0
- package/dist/edition.d.ts.map +1 -0
- package/dist/edition.js +19 -0
- package/dist/edition.js.map +1 -0
- package/dist/heartbeat/runner.d.ts.map +1 -1
- package/dist/heartbeat/runner.js +8 -0
- package/dist/heartbeat/runner.js.map +1 -1
- package/dist/idle/runner.d.ts.map +1 -1
- package/dist/idle/runner.js +7 -0
- package/dist/idle/runner.js.map +1 -1
- package/dist/mail/accounts.d.ts +20 -0
- package/dist/mail/accounts.d.ts.map +1 -0
- package/dist/mail/accounts.js +113 -0
- package/dist/mail/accounts.js.map +1 -0
- package/dist/mail/alerts.d.ts +29 -0
- package/dist/mail/alerts.d.ts.map +1 -0
- package/dist/mail/alerts.js +40 -0
- package/dist/mail/alerts.js.map +1 -0
- package/dist/mail/classify.d.ts +26 -0
- package/dist/mail/classify.d.ts.map +1 -0
- package/dist/mail/classify.js +191 -0
- package/dist/mail/classify.js.map +1 -0
- package/dist/mail/connectors/gmail.d.ts +55 -0
- package/dist/mail/connectors/gmail.d.ts.map +1 -0
- package/dist/mail/connectors/gmail.js +87 -0
- package/dist/mail/connectors/gmail.js.map +1 -0
- package/dist/mail/connectors/imap.d.ts +13 -0
- package/dist/mail/connectors/imap.d.ts.map +1 -0
- package/dist/mail/connectors/imap.js +136 -0
- package/dist/mail/connectors/imap.js.map +1 -0
- package/dist/mail/digest.d.ts +21 -0
- package/dist/mail/digest.d.ts.map +1 -0
- package/dist/mail/digest.js +89 -0
- package/dist/mail/digest.js.map +1 -0
- package/dist/mail/google-oauth.d.ts +50 -0
- package/dist/mail/google-oauth.d.ts.map +1 -0
- package/dist/mail/google-oauth.js +74 -0
- package/dist/mail/google-oauth.js.map +1 -0
- package/dist/mail/hosts.d.ts +3 -0
- package/dist/mail/hosts.d.ts.map +1 -0
- package/dist/mail/hosts.js +19 -0
- package/dist/mail/hosts.js.map +1 -0
- package/dist/mail/scheduler.d.ts +9 -0
- package/dist/mail/scheduler.d.ts.map +1 -0
- package/dist/mail/scheduler.js +21 -0
- package/dist/mail/scheduler.js.map +1 -0
- package/dist/mail/service.d.ts +32 -0
- package/dist/mail/service.d.ts.map +1 -0
- package/dist/mail/service.js +129 -0
- package/dist/mail/service.js.map +1 -0
- package/dist/mail/store.d.ts +7 -0
- package/dist/mail/store.d.ts.map +1 -0
- package/dist/mail/store.js +58 -0
- package/dist/mail/store.js.map +1 -0
- package/dist/mail/types.d.ts +114 -0
- package/dist/mail/types.d.ts.map +1 -0
- package/dist/mail/types.js +26 -0
- package/dist/mail/types.js.map +1 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +43 -21
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +67 -64
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +77 -71
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/registry.d.ts +10 -0
- package/dist/providers/registry.d.ts.map +1 -1
- package/dist/providers/registry.js +26 -0
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/stream-retry.d.ts +37 -0
- package/dist/providers/stream-retry.d.ts.map +1 -0
- package/dist/providers/stream-retry.js +92 -0
- package/dist/providers/stream-retry.js.map +1 -0
- package/dist/providers/types.d.ts +3 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/subagent.d.ts +2 -0
- package/dist/subagent.d.ts.map +1 -1
- package/dist/subagent.js +1 -0
- package/dist/subagent.js.map +1 -1
- package/dist/web/cloudAuth.d.ts +57 -0
- package/dist/web/cloudAuth.d.ts.map +1 -0
- package/dist/web/cloudAuth.js +139 -0
- package/dist/web/cloudAuth.js.map +1 -0
- package/dist/web/island.d.ts.map +1 -1
- package/dist/web/island.js +1 -0
- package/dist/web/island.js.map +1 -1
- package/dist/web/lisa-client.d.ts +1 -1
- package/dist/web/lisa-client.d.ts.map +1 -1
- package/dist/web/lisa-client.js +703 -40
- package/dist/web/lisa-client.js.map +1 -1
- package/dist/web/lisa-css.d.ts +1 -1
- package/dist/web/lisa-css.d.ts.map +1 -1
- package/dist/web/lisa-css.js +568 -43
- package/dist/web/lisa-css.js.map +1 -1
- package/dist/web/lisa-html.d.ts +1 -1
- package/dist/web/lisa-html.d.ts.map +1 -1
- package/dist/web/lisa-html.js +71 -21
- package/dist/web/lisa-html.js.map +1 -1
- package/dist/web/pairing.d.ts +37 -0
- package/dist/web/pairing.d.ts.map +1 -0
- package/dist/web/pairing.js +79 -0
- package/dist/web/pairing.js.map +1 -0
- package/dist/web/push.d.ts +12 -1
- package/dist/web/push.d.ts.map +1 -1
- package/dist/web/push.js +19 -4
- package/dist/web/push.js.map +1 -1
- package/dist/web/qr-svg.d.ts +11 -0
- package/dist/web/qr-svg.d.ts.map +1 -0
- package/dist/web/qr-svg.js +41 -0
- package/dist/web/qr-svg.js.map +1 -0
- package/dist/web/server.d.ts +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +342 -12
- package/dist/web/server.js.map +1 -1
- package/package.json +2 -1
package/dist/web/server.js
CHANGED
|
@@ -33,8 +33,16 @@ import { signalAgentTool } from "../tools/signal_agent.js";
|
|
|
33
33
|
import { managedRegistry } from "../agents/managed.js";
|
|
34
34
|
import { ptyRegistry, ptyEnabled, normalizeAgentKind } from "../agents/pty.js";
|
|
35
35
|
import { liveClaudeSessionIds } from "../integrations/claude-code/liveness.js";
|
|
36
|
+
import { sweepAll, pollNewMail } from "../mail/service.js";
|
|
37
|
+
import { pickImportant, formatAlert, alertLevel, pollMinutes } from "../mail/alerts.js";
|
|
38
|
+
import { latestDigest } from "../mail/store.js";
|
|
39
|
+
import { formatDigestText } from "../mail/digest.js";
|
|
40
|
+
import { isDigestDue, digestHour } from "../mail/scheduler.js";
|
|
41
|
+
import { loadAccounts, addAccount, removeAccount, setAccountEnabled } from "../mail/accounts.js";
|
|
42
|
+
import { inferHost } from "../mail/hosts.js";
|
|
36
43
|
import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js";
|
|
37
44
|
import { loadControlPolicy, saveControlPolicy } from "../control/policy.js";
|
|
45
|
+
import { loadAutonomyState, saveAutonomyState } from "../autonomy/state.js";
|
|
38
46
|
import { mintDevice, verifyDeviceToken, touchDevice, listDevices, revokeDevice } from "./devices.js";
|
|
39
47
|
import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs, registerLiveActivity, unregisterLiveActivity } from "./push.js";
|
|
40
48
|
import { SenseService } from "../sense/service.js";
|
|
@@ -44,6 +52,10 @@ import { appendSenseEvent, readSenseEvents } from "../sense/log.js";
|
|
|
44
52
|
import { loadScreenAdvisorConfig, saveScreenAdvisorConfig, normalizeConfig, analyzeScreenshot, } from "../screen_advisor/engine.js";
|
|
45
53
|
import os from "node:os";
|
|
46
54
|
import { LISA_HOME } from "../paths.js";
|
|
55
|
+
import { isCloud, editionInfo } from "../edition.js";
|
|
56
|
+
import { verifyAppleIdentityToken, fetchAppleKeys, appleSignInConfig, subAllowed, AppleAuthError, } from "./cloudAuth.js";
|
|
57
|
+
import { detectLanHost, buildPairUrl } from "./pairing.js";
|
|
58
|
+
import { qrSvg } from "./qr-svg.js";
|
|
47
59
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
48
60
|
const ASSETS_DIR = path.join(__dirname, "assets");
|
|
49
61
|
/** True for loopback peer/bind addresses (v4, v6, and v4-mapped-v6 forms). */
|
|
@@ -62,8 +74,8 @@ function timingSafeEqualStr(a, b) {
|
|
|
62
74
|
* is authorized iff it's loopback (the local user) OR it presents the correct
|
|
63
75
|
* LISA_WEB_TOKEN. No token configured ⇒ no non-loopback request can pass. Pure.
|
|
64
76
|
*/
|
|
65
|
-
export function isRequestAuthorized(remoteAddr, webToken, presented) {
|
|
66
|
-
if (isLoopbackAddress(remoteAddr))
|
|
77
|
+
export function isRequestAuthorized(remoteAddr, webToken, presented, trustLoopback = true) {
|
|
78
|
+
if (trustLoopback && isLoopbackAddress(remoteAddr))
|
|
67
79
|
return true;
|
|
68
80
|
return !!webToken && !!presented && timingSafeEqualStr(presented, webToken);
|
|
69
81
|
}
|
|
@@ -275,6 +287,92 @@ export async function startWebServer(opts) {
|
|
|
275
287
|
}, ADVISE_INTERVAL_MS);
|
|
276
288
|
if (adviseTimer.unref)
|
|
277
289
|
adviseTimer.unref();
|
|
290
|
+
// ── Mail digest (daily) ─────────────────────────────────────────────
|
|
291
|
+
// Once past the target hour, if not already done today, sweep all mailboxes,
|
|
292
|
+
// build + push the classified digest. Inert unless `mail` consent is granted
|
|
293
|
+
// and at least one account is connected.
|
|
294
|
+
let mailSweepRunning = false;
|
|
295
|
+
const afterMailDigest = (digest) => {
|
|
296
|
+
broadcast({
|
|
297
|
+
type: "mail_digest_update",
|
|
298
|
+
date: digest.date,
|
|
299
|
+
total: digest.total,
|
|
300
|
+
needsYou: digest.needsYou.length,
|
|
301
|
+
at: new Date().toISOString(),
|
|
302
|
+
});
|
|
303
|
+
pushBridge.onMailDigest(formatDigestText(digest));
|
|
304
|
+
};
|
|
305
|
+
const runMailDigest = async (force) => {
|
|
306
|
+
if (mailSweepRunning || !isGranted("mail"))
|
|
307
|
+
return null;
|
|
308
|
+
if (loadAccounts().filter((a) => a.enabled).length === 0)
|
|
309
|
+
return null;
|
|
310
|
+
if (!force && !isDigestDue(latestDigest()?.date ?? null, new Date(), digestHour()))
|
|
311
|
+
return null;
|
|
312
|
+
mailSweepRunning = true;
|
|
313
|
+
try {
|
|
314
|
+
const { digest } = await sweepAll();
|
|
315
|
+
afterMailDigest(digest);
|
|
316
|
+
// Post the digest into the chat too (the "here's your mail" moment), in
|
|
317
|
+
// addition to the push — but only on the scheduled daily run, not manual.
|
|
318
|
+
if (!force && digest.total > 0) {
|
|
319
|
+
broadcast({ type: "idle_message", text: formatDigestText(digest), at: new Date().toISOString(), source: "mail" });
|
|
320
|
+
}
|
|
321
|
+
console.error(`[mail] digest ${digest.date}: ${digest.total} mail · ${digest.needsYou.length} need-you`);
|
|
322
|
+
return digest;
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error(`[mail] digest sweep failed: ${err.message}`);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
mailSweepRunning = false;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const mailTimer = setInterval(() => void runMailDigest(false), 30 * 60_000);
|
|
333
|
+
if (mailTimer.unref)
|
|
334
|
+
mailTimer.unref();
|
|
335
|
+
// Catch a restart that happens after the target hour (don't wait 30m).
|
|
336
|
+
const mailKick = setTimeout(() => void runMailDigest(false), 20_000);
|
|
337
|
+
if (mailKick.unref)
|
|
338
|
+
mailKick.unref();
|
|
339
|
+
// ── Important-mail alerts (intraday) ────────────────────────────────
|
|
340
|
+
// Every LISA_MAIL_POLL_MINUTES (default 30; 0 disables), incrementally read
|
|
341
|
+
// NEW mail; for items at/above the alert level, fire a high-priority push +
|
|
342
|
+
// a proactive chat message. Inert without consent + an account.
|
|
343
|
+
const mailPollMin = pollMinutes();
|
|
344
|
+
if (mailPollMin > 0) {
|
|
345
|
+
let mailPollRunning = false;
|
|
346
|
+
const mailPoll = setInterval(() => {
|
|
347
|
+
void (async () => {
|
|
348
|
+
if (mailPollRunning || !isGranted("mail"))
|
|
349
|
+
return;
|
|
350
|
+
if (loadAccounts().filter((a) => a.enabled).length === 0)
|
|
351
|
+
return;
|
|
352
|
+
mailPollRunning = true;
|
|
353
|
+
try {
|
|
354
|
+
const important = pickImportant(await pollNewMail(), alertLevel());
|
|
355
|
+
if (important.length === 0)
|
|
356
|
+
return;
|
|
357
|
+
for (const item of important.slice(0, 3)) {
|
|
358
|
+
const alert = formatAlert(item);
|
|
359
|
+
pushBridge.onMailImportant({ title: alert.title, body: alert.body, tag: alert.tag });
|
|
360
|
+
broadcast({ type: "idle_message", text: alert.chat, at: new Date().toISOString(), source: "mail" });
|
|
361
|
+
}
|
|
362
|
+
broadcast({ type: "mail_digest_update", at: new Date().toISOString() });
|
|
363
|
+
console.error(`[mail] ${important.length} important new mail (alerted ${Math.min(3, important.length)})`);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
console.error(`[mail] poll failed: ${err.message}`);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
mailPollRunning = false;
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
}, mailPollMin * 60_000);
|
|
373
|
+
if (mailPoll.unref)
|
|
374
|
+
mailPoll.unref();
|
|
375
|
+
}
|
|
278
376
|
// ── Screen advisor (opt-in): periodic screen-grounded next-step ─────
|
|
279
377
|
// When enabled, every N minutes capture a full-screen shot, ask the model
|
|
280
378
|
// for the single best next coding step, and push it to the island as a
|
|
@@ -420,10 +518,67 @@ export async function startWebServer(opts) {
|
|
|
420
518
|
// full-tool agent and /api/vision/capture grabs the screen, so an
|
|
421
519
|
// unauthenticated non-loopback request is a remote-code-execution hole.
|
|
422
520
|
const remoteAddr = req.socket.remoteAddress ?? "";
|
|
423
|
-
|
|
521
|
+
// In the hosted cloud edition there is no trusted local user — the gate runs
|
|
522
|
+
// for EVERY request and loopback is not a free pass (the container must never
|
|
523
|
+
// trust its own/proxy loopback). Mac edition: loopback = the local owner.
|
|
524
|
+
const cloud = isCloud();
|
|
525
|
+
// ── Sign in with Apple (cloud edition) ───────────────────────────────────
|
|
526
|
+
// Public on purpose: this endpoint *mints* access, so it runs BEFORE the
|
|
527
|
+
// token gate. The iOS app sends the Apple identity token; we verify it
|
|
528
|
+
// against Apple's keys and hand back the cloud session token (= the shared
|
|
529
|
+
// LISA_WEB_TOKEN — single-tenant, matching the M0/C2 demo). Default-OFF:
|
|
530
|
+
// disabled unless LISA_CLOUD_APPLE_SIGNIN is set, so we never leak the token.
|
|
531
|
+
// See src/web/cloudAuth.ts + docs/PLAN_CLOUD_v1.0.md.
|
|
532
|
+
if (req.method === "POST" && url === "/api/auth/apple") {
|
|
533
|
+
const cfg = appleSignInConfig();
|
|
534
|
+
if (!cloud || !cfg.enabled) {
|
|
535
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
536
|
+
res.end("apple sign-in not available");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (!webToken) {
|
|
540
|
+
res.writeHead(503, { "content-type": "text/plain" });
|
|
541
|
+
res.end("cloud sign-in misconfigured (no session token)");
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
let abBody = "";
|
|
545
|
+
for await (const chunk of req)
|
|
546
|
+
abBody += chunk.toString("utf8");
|
|
547
|
+
let payload = {};
|
|
548
|
+
try {
|
|
549
|
+
payload = abBody ? JSON.parse(abBody) : {};
|
|
550
|
+
}
|
|
551
|
+
catch { /* tolerate */ }
|
|
552
|
+
const idToken = typeof payload.identityToken === "string" ? payload.identityToken : "";
|
|
553
|
+
if (!idToken) {
|
|
554
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
555
|
+
res.end("identityToken required");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
const id = await verifyAppleIdentityToken(idToken, {
|
|
560
|
+
audience: cfg.audience,
|
|
561
|
+
fetchKeys: fetchAppleKeys,
|
|
562
|
+
});
|
|
563
|
+
if (!subAllowed(id.sub, cfg)) {
|
|
564
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
565
|
+
res.end("this Apple ID is not allowed on this LISA Cloud instance");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
569
|
+
res.end(JSON.stringify({ ok: true, token: webToken }));
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
const msg = e instanceof AppleAuthError ? e.message : "verification failed";
|
|
573
|
+
res.writeHead(401, { "content-type": "text/plain" });
|
|
574
|
+
res.end(`apple sign-in rejected: ${msg}`);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (cloud || !isLoopbackAddress(remoteAddr)) {
|
|
424
579
|
const presented = webToken ? presentedToken(req, url) : null;
|
|
425
580
|
// Authorized by the global LISA_WEB_TOKEN OR a non-revoked per-device token.
|
|
426
|
-
let authed = isRequestAuthorized(remoteAddr, webToken, presented);
|
|
581
|
+
let authed = isRequestAuthorized(remoteAddr, webToken, presented, !cloud);
|
|
427
582
|
if (!authed && presented) {
|
|
428
583
|
const device = verifyDeviceToken(presented);
|
|
429
584
|
if (device) {
|
|
@@ -465,14 +620,24 @@ export async function startWebServer(opts) {
|
|
|
465
620
|
return true;
|
|
466
621
|
};
|
|
467
622
|
if (req.method === "GET" && (url === "/" || url.startsWith("/?"))) {
|
|
468
|
-
|
|
623
|
+
// no-store: never cache the shell, or a WKWebView / browser keeps rendering
|
|
624
|
+
// an old GUI after `lisa` is updated (the shell carries the current markup +
|
|
625
|
+
// asset references). Fixes the "removed element still shows" stale-cache bug.
|
|
626
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
469
627
|
res.end(MAIN_HTML);
|
|
470
628
|
return;
|
|
471
629
|
}
|
|
630
|
+
// Edition + capability descriptor — the client hides Mac-only surfaces
|
|
631
|
+
// (PTY/adopt, local dispatch, Sense, agent control) in the cloud edition.
|
|
632
|
+
if (req.method === "GET" && url === "/api/edition") {
|
|
633
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
634
|
+
res.end(JSON.stringify(editionInfo()));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
472
637
|
// Island widget — designed to be opened in a tiny browser window
|
|
473
638
|
// (Arc, Vivaldi PWA, Safari split). See docs/MAC_ISLAND_PLAN.md.
|
|
474
639
|
if (req.method === "GET" && (url === "/island" || url.startsWith("/island?"))) {
|
|
475
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
640
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
476
641
|
res.end(ISLAND_HTML);
|
|
477
642
|
return;
|
|
478
643
|
}
|
|
@@ -778,8 +943,21 @@ export async function startWebServer(opts) {
|
|
|
778
943
|
const name = typeof payload.name === "string" ? payload.name : "device";
|
|
779
944
|
const platform = typeof payload.platform === "string" ? payload.platform : "ios";
|
|
780
945
|
const { id, token, device } = mintDevice(name, platform);
|
|
946
|
+
// Detect a phone-reachable LAN host server-side so every client (CLI, Mac
|
|
947
|
+
// app, web UI) can show consistent, copyable pairing details — the browser
|
|
948
|
+
// can't discover the Mac's LAN IP on its own. A caller-supplied host wins.
|
|
949
|
+
const host = typeof payload.host === "string" && payload.host.trim()
|
|
950
|
+
? payload.host.trim()
|
|
951
|
+
: detectLanHost();
|
|
952
|
+
const url = host ? buildPairUrl(host, opts.port, token, name) : undefined;
|
|
953
|
+
// The interface the server is actually bound to. If it's loopback-only, a
|
|
954
|
+
// phone can't reach it however good the QR is — the client warns on this.
|
|
955
|
+
const boundHost = opts.host ?? "127.0.0.1";
|
|
956
|
+
// A scannable QR of the pairing link, for the web "Pair phone" modal (the
|
|
957
|
+
// browser equivalent of the CLI's terminal QR). Only when we have a url.
|
|
958
|
+
const qr = url ? qrSvg(url) : undefined;
|
|
781
959
|
res.writeHead(200, { "content-type": "application/json" });
|
|
782
|
-
res.end(JSON.stringify({ ok: true, id, token, port: opts.port, device }));
|
|
960
|
+
res.end(JSON.stringify({ ok: true, id, token, port: opts.port, host, url, boundHost, qrSvg: qr, device }));
|
|
783
961
|
return;
|
|
784
962
|
}
|
|
785
963
|
if (req.method === "GET" && url === "/api/devices") {
|
|
@@ -893,6 +1071,92 @@ export async function startWebServer(opts) {
|
|
|
893
1071
|
res.end(JSON.stringify(sub ? { ok: true, subscription: sub } : { ok: false }));
|
|
894
1072
|
return;
|
|
895
1073
|
}
|
|
1074
|
+
// ── Mail module (read-only): accounts, connect, sweep, digest ───────
|
|
1075
|
+
if (req.method === "GET" && url === "/api/mail/accounts") {
|
|
1076
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1077
|
+
res.end(JSON.stringify({ accounts: loadAccounts(), consent: isGranted("mail") }));
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (req.method === "GET" && url === "/api/mail/digest") {
|
|
1081
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1082
|
+
res.end(JSON.stringify({ digest: latestDigest() }));
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (req.method === "POST" && url === "/api/mail/connect") {
|
|
1086
|
+
let mBody = "";
|
|
1087
|
+
for await (const chunk of req)
|
|
1088
|
+
mBody += chunk.toString("utf8");
|
|
1089
|
+
let p;
|
|
1090
|
+
try {
|
|
1091
|
+
p = JSON.parse(mBody || "{}");
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1095
|
+
res.end("bad json");
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const email = typeof p.email === "string" ? p.email.trim() : "";
|
|
1099
|
+
const password = typeof p.password === "string" ? p.password : "";
|
|
1100
|
+
if (!email.includes("@") || !password) {
|
|
1101
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1102
|
+
res.end("email + password required");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const host = typeof p.host === "string" && p.host ? p.host : inferHost(email);
|
|
1106
|
+
if (!host) {
|
|
1107
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1108
|
+
res.end("couldn't infer IMAP host — pass host");
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const account = addAccount({
|
|
1112
|
+
provider: "imap",
|
|
1113
|
+
email,
|
|
1114
|
+
host,
|
|
1115
|
+
port: typeof p.port === "number" ? p.port : 993,
|
|
1116
|
+
label: typeof p.label === "string" ? p.label : undefined,
|
|
1117
|
+
}, { password });
|
|
1118
|
+
grant("mail"); // connecting a mailbox is the consent act
|
|
1119
|
+
broadcast({ type: "mail_accounts_update", at: new Date().toISOString() });
|
|
1120
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1121
|
+
res.end(JSON.stringify({ ok: true, account }));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (req.method === "POST" && url.startsWith("/api/mail/accounts/")) {
|
|
1125
|
+
const rest = url.slice("/api/mail/accounts/".length);
|
|
1126
|
+
const slash = rest.indexOf("/");
|
|
1127
|
+
const id = decodeURIComponent(slash >= 0 ? rest.slice(0, slash) : rest);
|
|
1128
|
+
const action = slash >= 0 ? rest.slice(slash + 1) : "";
|
|
1129
|
+
let ok = false;
|
|
1130
|
+
if (action === "remove")
|
|
1131
|
+
ok = removeAccount(id);
|
|
1132
|
+
else if (action === "enable")
|
|
1133
|
+
ok = setAccountEnabled(id, true);
|
|
1134
|
+
else if (action === "disable")
|
|
1135
|
+
ok = setAccountEnabled(id, false);
|
|
1136
|
+
else {
|
|
1137
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1138
|
+
res.end("action must be remove|enable|disable");
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (ok)
|
|
1142
|
+
broadcast({ type: "mail_accounts_update", at: new Date().toISOString() });
|
|
1143
|
+
res.writeHead(ok ? 200 : 404, { "content-type": "application/json" });
|
|
1144
|
+
res.end(JSON.stringify({ ok }));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (req.method === "POST" && url === "/api/mail/sweep") {
|
|
1148
|
+
if (!isGranted("mail")) {
|
|
1149
|
+
res.writeHead(409, { "content-type": "text/plain" });
|
|
1150
|
+
res.end("mail consent not granted");
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const swept = await sweepAll();
|
|
1154
|
+
if (!swept.blocked)
|
|
1155
|
+
afterMailDigest(swept.digest);
|
|
1156
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1157
|
+
res.end(JSON.stringify({ ok: !swept.blocked, digest: swept.digest, newItems: swept.newItems.length }));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
896
1160
|
// Remote-control policy: GET (any authed caller) reports it; POST sets it,
|
|
897
1161
|
// but only from localhost (the Mac owner) — like the API-key save.
|
|
898
1162
|
if (req.method === "GET" && url === "/api/control/policy") {
|
|
@@ -929,6 +1193,40 @@ export async function startWebServer(opts) {
|
|
|
929
1193
|
}
|
|
930
1194
|
return;
|
|
931
1195
|
}
|
|
1196
|
+
// Autonomy on/off — the "Proactive mode" master switch surfaced in the web
|
|
1197
|
+
// GUI + iOS as the Proactive toggle. GET reports it (any authed caller);
|
|
1198
|
+
// POST sets it. Unlike the control policy (loopback-only), this is allowed
|
|
1199
|
+
// from any authed device — letting Lisa rest is low-risk, and pausing her
|
|
1200
|
+
// from the phone is a primary use case. The token gate (above) still applies.
|
|
1201
|
+
if (req.method === "GET" && url === "/api/autonomy/state") {
|
|
1202
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1203
|
+
res.end(JSON.stringify(loadAutonomyState()));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (req.method === "POST" && url === "/api/autonomy/state") {
|
|
1207
|
+
let asBody = "";
|
|
1208
|
+
for await (const chunk of req)
|
|
1209
|
+
asBody += chunk.toString("utf8");
|
|
1210
|
+
let payload;
|
|
1211
|
+
try {
|
|
1212
|
+
payload = JSON.parse(asBody || "{}");
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1216
|
+
res.end("bad json");
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
try {
|
|
1220
|
+
const saved = saveAutonomyState({ ...loadAutonomyState(), ...payload });
|
|
1221
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1222
|
+
res.end(JSON.stringify(saved));
|
|
1223
|
+
}
|
|
1224
|
+
catch (err) {
|
|
1225
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
1226
|
+
res.end(err.message);
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
932
1230
|
// Structured view of the agents LISA dispatched fire-and-forget (the ledger).
|
|
933
1231
|
// Complements /api/agent/signal's action:"list" (which returns prose): this is
|
|
934
1232
|
// JSON for clients (the iOS roster). Structural only — never the captured log.
|
|
@@ -1678,12 +1976,34 @@ self.addEventListener('fetch', (event) => {
|
|
|
1678
1976
|
"cache-control": "no-cache",
|
|
1679
1977
|
connection: "keep-alive",
|
|
1680
1978
|
});
|
|
1681
|
-
|
|
1979
|
+
// Guard writes: once the client disconnects the socket is gone, and a bare
|
|
1980
|
+
// res.write would throw "write after end" from inside the agent loop.
|
|
1981
|
+
const send = (event) => {
|
|
1982
|
+
if (res.writableEnded || res.destroyed)
|
|
1983
|
+
return;
|
|
1984
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1985
|
+
};
|
|
1986
|
+
// Per-turn cancellation: if the client disconnects (taps Stop / closes the
|
|
1987
|
+
// app), abort THIS turn's agent so it stops burning tokens and the next
|
|
1988
|
+
// queued turn isn't stuck behind an abandoned run.
|
|
1989
|
+
const turnAbort = new AbortController();
|
|
1990
|
+
req.on("close", () => turnAbort.abort());
|
|
1682
1991
|
const onMood = (slug) => send({ type: "mood", slug });
|
|
1683
1992
|
moodBus.on("mood", onMood);
|
|
1684
1993
|
// Send the current mood immediately so a fresh tab knows where to start.
|
|
1685
1994
|
send({ type: "mood", slug: moodBus.current() });
|
|
1686
1995
|
const runTurn = async () => {
|
|
1996
|
+
// runAgent emits an error event for a failed turn AND rethrows; without
|
|
1997
|
+
// this guard the catch below would send a second, identical error event
|
|
1998
|
+
// (the client used to render the same error twice).
|
|
1999
|
+
let errorSent = false;
|
|
2000
|
+
// Track whether the turn produced anything visible. A model call can
|
|
2001
|
+
// return a clean, successful turn with zero text and zero tools (e.g. a
|
|
2002
|
+
// provider hiccup returns an empty completion). Without a signal the
|
|
2003
|
+
// client can only guess ("(no response)"); we emit an explicit `empty`
|
|
2004
|
+
// event so it can offer a retry instead of a dead end.
|
|
2005
|
+
let anyText = false;
|
|
2006
|
+
let anyTool = false;
|
|
1687
2007
|
try {
|
|
1688
2008
|
// Use the freshest cached prompt for this chat. If soul / skills /
|
|
1689
2009
|
// memory changed since the previous chat, rebuildPrompt() picks it up.
|
|
@@ -1694,7 +2014,8 @@ self.addEventListener('fetch', (event) => {
|
|
|
1694
2014
|
tools: opts.tools,
|
|
1695
2015
|
toolCtx: {
|
|
1696
2016
|
cwd: process.cwd(),
|
|
1697
|
-
|
|
2017
|
+
// Abort on server shutdown OR this client disconnecting (Stop).
|
|
2018
|
+
signal: AbortSignal.any([abort.signal, turnAbort.signal]),
|
|
1698
2019
|
log: () => { },
|
|
1699
2020
|
},
|
|
1700
2021
|
history,
|
|
@@ -1703,8 +2024,12 @@ self.addEventListener('fetch', (event) => {
|
|
|
1703
2024
|
model: opts.model,
|
|
1704
2025
|
thinking: opts.thinking,
|
|
1705
2026
|
onEvent: (ev) => {
|
|
1706
|
-
if (ev.type === "text_delta" && ev.text)
|
|
2027
|
+
if (ev.type === "text_delta" && ev.text) {
|
|
2028
|
+
anyText = true;
|
|
1707
2029
|
send({ type: "text", text: ev.text });
|
|
2030
|
+
}
|
|
2031
|
+
if (ev.type === "tool_call_start")
|
|
2032
|
+
anyTool = true;
|
|
1708
2033
|
if (ev.type === "tool_call_start")
|
|
1709
2034
|
send({
|
|
1710
2035
|
type: "tool_start",
|
|
@@ -1722,8 +2047,10 @@ self.addEventListener('fetch', (event) => {
|
|
|
1722
2047
|
});
|
|
1723
2048
|
if (ev.type === "system_prompt_rebuilt")
|
|
1724
2049
|
send({ type: "soul_reload", message: ev.message ?? "" });
|
|
1725
|
-
if (ev.type === "error")
|
|
2050
|
+
if (ev.type === "error") {
|
|
2051
|
+
errorSent = true;
|
|
1726
2052
|
send({ type: "error", message: ev.message ?? "" });
|
|
2053
|
+
}
|
|
1727
2054
|
},
|
|
1728
2055
|
// Same plugin hook wiring as the CLI turn — PreToolUse can block,
|
|
1729
2056
|
// PostToolUse can rewrite. (Was CLI-only; web tool calls bypassed
|
|
@@ -1754,10 +2081,13 @@ self.addEventListener('fetch', (event) => {
|
|
|
1754
2081
|
});
|
|
1755
2082
|
history.length = 0;
|
|
1756
2083
|
history.push(...result.history);
|
|
2084
|
+
if (!anyText && !anyTool && !errorSent)
|
|
2085
|
+
send({ type: "empty" });
|
|
1757
2086
|
send({ type: "done" });
|
|
1758
2087
|
}
|
|
1759
2088
|
catch (err) {
|
|
1760
|
-
|
|
2089
|
+
if (!errorSent)
|
|
2090
|
+
send({ type: "error", message: err.message });
|
|
1761
2091
|
}
|
|
1762
2092
|
finally {
|
|
1763
2093
|
moodBus.off("mood", onMood);
|