@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.
Files changed (139) hide show
  1. package/dist/agent.d.ts +2 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +20 -1
  4. package/dist/agent.js.map +1 -1
  5. package/dist/autonomy/state.d.ts +14 -0
  6. package/dist/autonomy/state.d.ts.map +1 -0
  7. package/dist/autonomy/state.js +54 -0
  8. package/dist/autonomy/state.js.map +1 -0
  9. package/dist/cli/mail.d.ts +2 -0
  10. package/dist/cli/mail.d.ts.map +1 -0
  11. package/dist/cli/mail.js +209 -0
  12. package/dist/cli/mail.js.map +1 -0
  13. package/dist/cli/pair.d.ts +1 -5
  14. package/dist/cli/pair.d.ts.map +1 -1
  15. package/dist/cli/pair.js +58 -20
  16. package/dist/cli/pair.js.map +1 -1
  17. package/dist/cli.js +15 -14
  18. package/dist/cli.js.map +1 -1
  19. package/dist/consent/store.d.ts +2 -2
  20. package/dist/consent/store.d.ts.map +1 -1
  21. package/dist/consent/store.js +3 -2
  22. package/dist/consent/store.js.map +1 -1
  23. package/dist/edition.d.ts +24 -0
  24. package/dist/edition.d.ts.map +1 -0
  25. package/dist/edition.js +19 -0
  26. package/dist/edition.js.map +1 -0
  27. package/dist/heartbeat/runner.d.ts.map +1 -1
  28. package/dist/heartbeat/runner.js +8 -0
  29. package/dist/heartbeat/runner.js.map +1 -1
  30. package/dist/idle/runner.d.ts.map +1 -1
  31. package/dist/idle/runner.js +7 -0
  32. package/dist/idle/runner.js.map +1 -1
  33. package/dist/mail/accounts.d.ts +20 -0
  34. package/dist/mail/accounts.d.ts.map +1 -0
  35. package/dist/mail/accounts.js +113 -0
  36. package/dist/mail/accounts.js.map +1 -0
  37. package/dist/mail/alerts.d.ts +29 -0
  38. package/dist/mail/alerts.d.ts.map +1 -0
  39. package/dist/mail/alerts.js +40 -0
  40. package/dist/mail/alerts.js.map +1 -0
  41. package/dist/mail/classify.d.ts +26 -0
  42. package/dist/mail/classify.d.ts.map +1 -0
  43. package/dist/mail/classify.js +191 -0
  44. package/dist/mail/classify.js.map +1 -0
  45. package/dist/mail/connectors/gmail.d.ts +55 -0
  46. package/dist/mail/connectors/gmail.d.ts.map +1 -0
  47. package/dist/mail/connectors/gmail.js +87 -0
  48. package/dist/mail/connectors/gmail.js.map +1 -0
  49. package/dist/mail/connectors/imap.d.ts +13 -0
  50. package/dist/mail/connectors/imap.d.ts.map +1 -0
  51. package/dist/mail/connectors/imap.js +136 -0
  52. package/dist/mail/connectors/imap.js.map +1 -0
  53. package/dist/mail/digest.d.ts +21 -0
  54. package/dist/mail/digest.d.ts.map +1 -0
  55. package/dist/mail/digest.js +89 -0
  56. package/dist/mail/digest.js.map +1 -0
  57. package/dist/mail/google-oauth.d.ts +50 -0
  58. package/dist/mail/google-oauth.d.ts.map +1 -0
  59. package/dist/mail/google-oauth.js +74 -0
  60. package/dist/mail/google-oauth.js.map +1 -0
  61. package/dist/mail/hosts.d.ts +3 -0
  62. package/dist/mail/hosts.d.ts.map +1 -0
  63. package/dist/mail/hosts.js +19 -0
  64. package/dist/mail/hosts.js.map +1 -0
  65. package/dist/mail/scheduler.d.ts +9 -0
  66. package/dist/mail/scheduler.d.ts.map +1 -0
  67. package/dist/mail/scheduler.js +21 -0
  68. package/dist/mail/scheduler.js.map +1 -0
  69. package/dist/mail/service.d.ts +32 -0
  70. package/dist/mail/service.d.ts.map +1 -0
  71. package/dist/mail/service.js +129 -0
  72. package/dist/mail/service.js.map +1 -0
  73. package/dist/mail/store.d.ts +7 -0
  74. package/dist/mail/store.d.ts.map +1 -0
  75. package/dist/mail/store.js +58 -0
  76. package/dist/mail/store.js.map +1 -0
  77. package/dist/mail/types.d.ts +114 -0
  78. package/dist/mail/types.d.ts.map +1 -0
  79. package/dist/mail/types.js +26 -0
  80. package/dist/mail/types.js.map +1 -0
  81. package/dist/providers/anthropic.d.ts.map +1 -1
  82. package/dist/providers/anthropic.js +43 -21
  83. package/dist/providers/anthropic.js.map +1 -1
  84. package/dist/providers/gemini.d.ts.map +1 -1
  85. package/dist/providers/gemini.js +67 -64
  86. package/dist/providers/gemini.js.map +1 -1
  87. package/dist/providers/openai.d.ts.map +1 -1
  88. package/dist/providers/openai.js +77 -71
  89. package/dist/providers/openai.js.map +1 -1
  90. package/dist/providers/registry.d.ts +10 -0
  91. package/dist/providers/registry.d.ts.map +1 -1
  92. package/dist/providers/registry.js +26 -0
  93. package/dist/providers/registry.js.map +1 -1
  94. package/dist/providers/stream-retry.d.ts +37 -0
  95. package/dist/providers/stream-retry.d.ts.map +1 -0
  96. package/dist/providers/stream-retry.js +92 -0
  97. package/dist/providers/stream-retry.js.map +1 -0
  98. package/dist/providers/types.d.ts +3 -0
  99. package/dist/providers/types.d.ts.map +1 -1
  100. package/dist/subagent.d.ts +2 -0
  101. package/dist/subagent.d.ts.map +1 -1
  102. package/dist/subagent.js +1 -0
  103. package/dist/subagent.js.map +1 -1
  104. package/dist/web/cloudAuth.d.ts +57 -0
  105. package/dist/web/cloudAuth.d.ts.map +1 -0
  106. package/dist/web/cloudAuth.js +139 -0
  107. package/dist/web/cloudAuth.js.map +1 -0
  108. package/dist/web/island.d.ts.map +1 -1
  109. package/dist/web/island.js +1 -0
  110. package/dist/web/island.js.map +1 -1
  111. package/dist/web/lisa-client.d.ts +1 -1
  112. package/dist/web/lisa-client.d.ts.map +1 -1
  113. package/dist/web/lisa-client.js +703 -40
  114. package/dist/web/lisa-client.js.map +1 -1
  115. package/dist/web/lisa-css.d.ts +1 -1
  116. package/dist/web/lisa-css.d.ts.map +1 -1
  117. package/dist/web/lisa-css.js +568 -43
  118. package/dist/web/lisa-css.js.map +1 -1
  119. package/dist/web/lisa-html.d.ts +1 -1
  120. package/dist/web/lisa-html.d.ts.map +1 -1
  121. package/dist/web/lisa-html.js +71 -21
  122. package/dist/web/lisa-html.js.map +1 -1
  123. package/dist/web/pairing.d.ts +37 -0
  124. package/dist/web/pairing.d.ts.map +1 -0
  125. package/dist/web/pairing.js +79 -0
  126. package/dist/web/pairing.js.map +1 -0
  127. package/dist/web/push.d.ts +12 -1
  128. package/dist/web/push.d.ts.map +1 -1
  129. package/dist/web/push.js +19 -4
  130. package/dist/web/push.js.map +1 -1
  131. package/dist/web/qr-svg.d.ts +11 -0
  132. package/dist/web/qr-svg.d.ts.map +1 -0
  133. package/dist/web/qr-svg.js +41 -0
  134. package/dist/web/qr-svg.js.map +1 -0
  135. package/dist/web/server.d.ts +1 -1
  136. package/dist/web/server.d.ts.map +1 -1
  137. package/dist/web/server.js +342 -12
  138. package/dist/web/server.js.map +1 -1
  139. package/package.json +2 -1
@@ -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
- if (!isLoopbackAddress(remoteAddr)) {
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
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
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
- const send = (event) => res.write(`data: ${JSON.stringify(event)}\n\n`);
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
- signal: abort.signal,
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
- send({ type: "error", message: err.message });
2089
+ if (!errorSent)
2090
+ send({ type: "error", message: err.message });
1761
2091
  }
1762
2092
  finally {
1763
2093
  moodBus.off("mood", onMood);