@oratis/lisa 0.9.0 → 0.11.1

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 (296) hide show
  1. package/README.md +123 -16
  2. package/README.zh-CN.md +101 -15
  3. package/completions/_lisa +56 -0
  4. package/completions/lisa.bash +35 -1
  5. package/completions/lisa.fish +18 -1
  6. package/dist/advisor/engine.d.ts +7 -0
  7. package/dist/advisor/engine.d.ts.map +1 -1
  8. package/dist/advisor/engine.js +12 -0
  9. package/dist/advisor/engine.js.map +1 -1
  10. package/dist/agent.d.ts +14 -0
  11. package/dist/agent.d.ts.map +1 -1
  12. package/dist/agent.js +58 -7
  13. package/dist/agent.js.map +1 -1
  14. package/dist/agents/managed.d.ts +108 -0
  15. package/dist/agents/managed.d.ts.map +1 -0
  16. package/dist/agents/managed.js +241 -0
  17. package/dist/agents/managed.js.map +1 -0
  18. package/dist/agents/pty.d.ts +155 -0
  19. package/dist/agents/pty.d.ts.map +1 -0
  20. package/dist/agents/pty.js +299 -0
  21. package/dist/agents/pty.js.map +1 -0
  22. package/dist/approval.d.ts +13 -0
  23. package/dist/approval.d.ts.map +1 -1
  24. package/dist/approval.js +25 -2
  25. package/dist/approval.js.map +1 -1
  26. package/dist/autonomy/runs.d.ts +33 -0
  27. package/dist/autonomy/runs.d.ts.map +1 -0
  28. package/dist/autonomy/runs.js +106 -0
  29. package/dist/autonomy/runs.js.map +1 -0
  30. package/dist/channels/feishu.d.ts.map +1 -1
  31. package/dist/channels/feishu.js +65 -0
  32. package/dist/channels/feishu.js.map +1 -1
  33. package/dist/channels/router.d.ts +7 -0
  34. package/dist/channels/router.d.ts.map +1 -1
  35. package/dist/channels/router.js +1 -1
  36. package/dist/channels/router.js.map +1 -1
  37. package/dist/cli/agents-pty.d.ts +18 -0
  38. package/dist/cli/agents-pty.d.ts.map +1 -0
  39. package/dist/cli/agents-pty.js +182 -0
  40. package/dist/cli/agents-pty.js.map +1 -0
  41. package/dist/cli/agents.d.ts +2 -0
  42. package/dist/cli/agents.d.ts.map +1 -0
  43. package/dist/cli/agents.js +65 -0
  44. package/dist/cli/agents.js.map +1 -0
  45. package/dist/cli/consent.d.ts +2 -0
  46. package/dist/cli/consent.d.ts.map +1 -0
  47. package/dist/cli/consent.js +57 -0
  48. package/dist/cli/consent.js.map +1 -0
  49. package/dist/cli/model.d.ts +2 -0
  50. package/dist/cli/model.d.ts.map +1 -0
  51. package/dist/cli/model.js +121 -0
  52. package/dist/cli/model.js.map +1 -0
  53. package/dist/cli/sense.d.ts +2 -0
  54. package/dist/cli/sense.d.ts.map +1 -0
  55. package/dist/cli/sense.js +36 -0
  56. package/dist/cli/sense.js.map +1 -0
  57. package/dist/cli.js +117 -12
  58. package/dist/cli.js.map +1 -1
  59. package/dist/consent/blacklist.d.ts +28 -0
  60. package/dist/consent/blacklist.d.ts.map +1 -0
  61. package/dist/consent/blacklist.js +78 -0
  62. package/dist/consent/blacklist.js.map +1 -0
  63. package/dist/consent/store.d.ts +45 -0
  64. package/dist/consent/store.d.ts.map +1 -0
  65. package/dist/consent/store.js +126 -0
  66. package/dist/consent/store.js.map +1 -0
  67. package/dist/control/policy.d.ts +13 -0
  68. package/dist/control/policy.d.ts.map +1 -0
  69. package/dist/control/policy.js +57 -0
  70. package/dist/control/policy.js.map +1 -0
  71. package/dist/heartbeat/runner.d.ts.map +1 -1
  72. package/dist/heartbeat/runner.js +70 -14
  73. package/dist/heartbeat/runner.js.map +1 -1
  74. package/dist/idle/runner.d.ts.map +1 -1
  75. package/dist/idle/runner.js +87 -17
  76. package/dist/idle/runner.js.map +1 -1
  77. package/dist/integrations/aider/observer.d.ts +21 -1
  78. package/dist/integrations/aider/observer.d.ts.map +1 -1
  79. package/dist/integrations/aider/observer.js +110 -1
  80. package/dist/integrations/aider/observer.js.map +1 -1
  81. package/dist/integrations/claude-code/liveness.d.ts +10 -0
  82. package/dist/integrations/claude-code/liveness.d.ts.map +1 -0
  83. package/dist/integrations/claude-code/liveness.js +62 -0
  84. package/dist/integrations/claude-code/liveness.js.map +1 -0
  85. package/dist/integrations/claude-code/watcher.d.ts +39 -4
  86. package/dist/integrations/claude-code/watcher.d.ts.map +1 -1
  87. package/dist/integrations/claude-code/watcher.js +94 -57
  88. package/dist/integrations/claude-code/watcher.js.map +1 -1
  89. package/dist/integrations/codex/observer.d.ts +19 -6
  90. package/dist/integrations/codex/observer.d.ts.map +1 -1
  91. package/dist/integrations/codex/observer.js +228 -4
  92. package/dist/integrations/codex/observer.js.map +1 -1
  93. package/dist/integrations/dispatch-ledger.d.ts +30 -1
  94. package/dist/integrations/dispatch-ledger.d.ts.map +1 -1
  95. package/dist/integrations/dispatch-ledger.js +75 -9
  96. package/dist/integrations/dispatch-ledger.js.map +1 -1
  97. package/dist/integrations/git/branch.d.ts +11 -0
  98. package/dist/integrations/git/branch.d.ts.map +1 -0
  99. package/dist/integrations/git/branch.js +49 -0
  100. package/dist/integrations/git/branch.js.map +1 -0
  101. package/dist/integrations/git/observer.d.ts +39 -0
  102. package/dist/integrations/git/observer.d.ts.map +1 -0
  103. package/dist/integrations/git/observer.js +252 -0
  104. package/dist/integrations/git/observer.js.map +1 -0
  105. package/dist/integrations/github-pr/observer.d.ts.map +1 -1
  106. package/dist/integrations/github-pr/observer.js +7 -2
  107. package/dist/integrations/github-pr/observer.js.map +1 -1
  108. package/dist/integrations/hub.d.ts +8 -0
  109. package/dist/integrations/hub.d.ts.map +1 -1
  110. package/dist/integrations/hub.js +39 -1
  111. package/dist/integrations/hub.js.map +1 -1
  112. package/dist/integrations/managed/observer.d.ts +17 -0
  113. package/dist/integrations/managed/observer.d.ts.map +1 -0
  114. package/dist/integrations/managed/observer.js +46 -0
  115. package/dist/integrations/managed/observer.js.map +1 -0
  116. package/dist/integrations/opencode/observer.d.ts +53 -5
  117. package/dist/integrations/opencode/observer.d.ts.map +1 -1
  118. package/dist/integrations/opencode/observer.js +242 -20
  119. package/dist/integrations/opencode/observer.js.map +1 -1
  120. package/dist/integrations/pty/observer.d.ts +25 -0
  121. package/dist/integrations/pty/observer.d.ts.map +1 -0
  122. package/dist/integrations/pty/observer.js +48 -0
  123. package/dist/integrations/pty/observer.js.map +1 -0
  124. package/dist/integrations/registry.d.ts.map +1 -1
  125. package/dist/integrations/registry.js +5 -0
  126. package/dist/integrations/registry.js.map +1 -1
  127. package/dist/integrations/shell/observer.d.ts +33 -0
  128. package/dist/integrations/shell/observer.d.ts.map +1 -0
  129. package/dist/integrations/shell/observer.js +223 -0
  130. package/dist/integrations/shell/observer.js.map +1 -0
  131. package/dist/integrations/takoapi/a2a.d.ts +38 -0
  132. package/dist/integrations/takoapi/a2a.d.ts.map +1 -0
  133. package/dist/integrations/takoapi/a2a.js +70 -0
  134. package/dist/integrations/takoapi/a2a.js.map +1 -0
  135. package/dist/integrations/takoapi/ledger.d.ts +32 -0
  136. package/dist/integrations/takoapi/ledger.d.ts.map +1 -0
  137. package/dist/integrations/takoapi/ledger.js +96 -0
  138. package/dist/integrations/takoapi/ledger.js.map +1 -0
  139. package/dist/integrations/takoapi/observer.d.ts +39 -0
  140. package/dist/integrations/takoapi/observer.d.ts.map +1 -0
  141. package/dist/integrations/takoapi/observer.js +102 -0
  142. package/dist/integrations/takoapi/observer.js.map +1 -0
  143. package/dist/integrations/types.d.ts +17 -0
  144. package/dist/integrations/types.d.ts.map +1 -1
  145. package/dist/mcp/client.d.ts +5 -0
  146. package/dist/mcp/client.d.ts.map +1 -1
  147. package/dist/mcp/client.js +1 -1
  148. package/dist/mcp/client.js.map +1 -1
  149. package/dist/memory/embedding.d.ts +45 -0
  150. package/dist/memory/embedding.d.ts.map +1 -0
  151. package/dist/memory/embedding.js +139 -0
  152. package/dist/memory/embedding.js.map +1 -0
  153. package/dist/memory/search_tool.d.ts.map +1 -1
  154. package/dist/memory/search_tool.js +6 -2
  155. package/dist/memory/search_tool.js.map +1 -1
  156. package/dist/memory/vector.d.ts +14 -3
  157. package/dist/memory/vector.d.ts.map +1 -1
  158. package/dist/memory/vector.js +50 -0
  159. package/dist/memory/vector.js.map +1 -1
  160. package/dist/model/local.d.ts +43 -0
  161. package/dist/model/local.d.ts.map +1 -0
  162. package/dist/model/local.js +118 -0
  163. package/dist/model/local.js.map +1 -0
  164. package/dist/model/plan-usage.d.ts +41 -0
  165. package/dist/model/plan-usage.d.ts.map +1 -0
  166. package/dist/model/plan-usage.js +168 -0
  167. package/dist/model/plan-usage.js.map +1 -0
  168. package/dist/model/plans.d.ts +73 -0
  169. package/dist/model/plans.d.ts.map +1 -0
  170. package/dist/model/plans.js +246 -0
  171. package/dist/model/plans.js.map +1 -0
  172. package/dist/orchestrator/recent-recap.d.ts +2 -0
  173. package/dist/orchestrator/recent-recap.d.ts.map +1 -0
  174. package/dist/orchestrator/recent-recap.js +20 -0
  175. package/dist/orchestrator/recent-recap.js.map +1 -0
  176. package/dist/providers/anthropic.d.ts +1 -0
  177. package/dist/providers/anthropic.d.ts.map +1 -1
  178. package/dist/providers/anthropic.js +10 -2
  179. package/dist/providers/anthropic.js.map +1 -1
  180. package/dist/providers/fallback.d.ts +23 -0
  181. package/dist/providers/fallback.d.ts.map +1 -0
  182. package/dist/providers/fallback.js +29 -0
  183. package/dist/providers/fallback.js.map +1 -0
  184. package/dist/providers/gemini.d.ts +2 -0
  185. package/dist/providers/gemini.d.ts.map +1 -1
  186. package/dist/providers/gemini.js +24 -10
  187. package/dist/providers/gemini.js.map +1 -1
  188. package/dist/providers/openai.d.ts.map +1 -1
  189. package/dist/providers/openai.js +4 -1
  190. package/dist/providers/openai.js.map +1 -1
  191. package/dist/providers/registry.d.ts +18 -2
  192. package/dist/providers/registry.d.ts.map +1 -1
  193. package/dist/providers/registry.js +56 -9
  194. package/dist/providers/registry.js.map +1 -1
  195. package/dist/reflect.d.ts +55 -0
  196. package/dist/reflect.d.ts.map +1 -1
  197. package/dist/reflect.js +148 -54
  198. package/dist/reflect.js.map +1 -1
  199. package/dist/sense/log.d.ts +9 -0
  200. package/dist/sense/log.d.ts.map +1 -0
  201. package/dist/sense/log.js +63 -0
  202. package/dist/sense/log.js.map +1 -0
  203. package/dist/sense/screen.d.ts +43 -0
  204. package/dist/sense/screen.d.ts.map +1 -0
  205. package/dist/sense/screen.js +107 -0
  206. package/dist/sense/screen.js.map +1 -0
  207. package/dist/sense/service.d.ts +16 -0
  208. package/dist/sense/service.d.ts.map +1 -0
  209. package/dist/sense/service.js +33 -0
  210. package/dist/sense/service.js.map +1 -0
  211. package/dist/sense/types.d.ts +35 -0
  212. package/dist/sense/types.d.ts.map +1 -0
  213. package/dist/sense/types.js +11 -0
  214. package/dist/sense/types.js.map +1 -0
  215. package/dist/sense/voice.d.ts +28 -0
  216. package/dist/sense/voice.d.ts.map +1 -0
  217. package/dist/sense/voice.js +64 -0
  218. package/dist/sense/voice.js.map +1 -0
  219. package/dist/soul/git.d.ts.map +1 -1
  220. package/dist/soul/git.js +35 -16
  221. package/dist/soul/git.js.map +1 -1
  222. package/dist/soul/store.d.ts +22 -0
  223. package/dist/soul/store.d.ts.map +1 -1
  224. package/dist/soul/store.js +69 -14
  225. package/dist/soul/store.js.map +1 -1
  226. package/dist/soul/summary.d.ts +10 -0
  227. package/dist/soul/summary.d.ts.map +1 -0
  228. package/dist/soul/summary.js +57 -0
  229. package/dist/soul/summary.js.map +1 -0
  230. package/dist/soul/tools.d.ts.map +1 -1
  231. package/dist/soul/tools.js +13 -24
  232. package/dist/soul/tools.js.map +1 -1
  233. package/dist/soul/types.d.ts +9 -0
  234. package/dist/soul/types.d.ts.map +1 -1
  235. package/dist/soul/types.js.map +1 -1
  236. package/dist/subagent.d.ts +7 -0
  237. package/dist/subagent.d.ts.map +1 -1
  238. package/dist/subagent.js +3 -1
  239. package/dist/subagent.js.map +1 -1
  240. package/dist/tools/dispatch_agent.d.ts +13 -1
  241. package/dist/tools/dispatch_agent.d.ts.map +1 -1
  242. package/dist/tools/dispatch_agent.js +56 -12
  243. package/dist/tools/dispatch_agent.js.map +1 -1
  244. package/dist/tools/dispatch_status.d.ts +22 -0
  245. package/dist/tools/dispatch_status.d.ts.map +1 -0
  246. package/dist/tools/dispatch_status.js +57 -0
  247. package/dist/tools/dispatch_status.js.map +1 -0
  248. package/dist/tools/registry.d.ts +29 -0
  249. package/dist/tools/registry.d.ts.map +1 -1
  250. package/dist/tools/registry.js +67 -0
  251. package/dist/tools/registry.js.map +1 -1
  252. package/dist/tools/run_on_plan.d.ts +46 -0
  253. package/dist/tools/run_on_plan.d.ts.map +1 -0
  254. package/dist/tools/run_on_plan.js +89 -0
  255. package/dist/tools/run_on_plan.js.map +1 -0
  256. package/dist/tools/takoapi.d.ts +60 -0
  257. package/dist/tools/takoapi.d.ts.map +1 -0
  258. package/dist/tools/takoapi.js +181 -0
  259. package/dist/tools/takoapi.js.map +1 -0
  260. package/dist/tools/validate.d.ts +22 -0
  261. package/dist/tools/validate.d.ts.map +1 -0
  262. package/dist/tools/validate.js +50 -0
  263. package/dist/tools/validate.js.map +1 -0
  264. package/dist/web/agent-roster.d.ts +56 -0
  265. package/dist/web/agent-roster.d.ts.map +1 -0
  266. package/dist/web/agent-roster.js +109 -0
  267. package/dist/web/agent-roster.js.map +1 -0
  268. package/dist/web/devices.d.ts +27 -0
  269. package/dist/web/devices.d.ts.map +1 -0
  270. package/dist/web/devices.js +113 -0
  271. package/dist/web/devices.js.map +1 -0
  272. package/dist/web/island.d.ts +5 -1
  273. package/dist/web/island.d.ts.map +1 -1
  274. package/dist/web/island.js +410 -63
  275. package/dist/web/island.js.map +1 -1
  276. package/dist/web/lisa-client.d.ts +20 -0
  277. package/dist/web/lisa-client.d.ts.map +1 -0
  278. package/dist/web/lisa-client.js +1358 -0
  279. package/dist/web/lisa-client.js.map +1 -0
  280. package/dist/web/lisa-css.d.ts +13 -0
  281. package/dist/web/lisa-css.d.ts.map +1 -0
  282. package/dist/web/lisa-css.js +1163 -0
  283. package/dist/web/lisa-css.js.map +1 -0
  284. package/dist/web/lisa-html.d.ts +1 -1
  285. package/dist/web/lisa-html.d.ts.map +1 -1
  286. package/dist/web/lisa-html.js +15 -2136
  287. package/dist/web/lisa-html.js.map +1 -1
  288. package/dist/web/push.d.ts +76 -0
  289. package/dist/web/push.d.ts.map +1 -0
  290. package/dist/web/push.js +163 -0
  291. package/dist/web/push.js.map +1 -0
  292. package/dist/web/server.d.ts +19 -0
  293. package/dist/web/server.d.ts.map +1 -1
  294. package/dist/web/server.js +878 -64
  295. package/dist/web/server.js.map +1 -1
  296. package/package.json +5 -2
@@ -1,9 +1,13 @@
1
1
  import http from "node:http";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs/promises";
4
+ import crypto from "node:crypto";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { runAgent } from "../agent.js";
7
+ import { fireHooks } from "../hooks/runner.js";
6
8
  import { saveConfigEnv } from "../env.js";
9
+ import { detectPlans, selectedPlan, parsePlanRef, planMark, PLAN_IDS } from "../model/plans.js";
10
+ import { planUsage, formatUsage } from "../model/plan-usage.js";
7
11
  import { runIdleOnce } from "../idle/runner.js";
8
12
  import { getIdleWatcher } from "../idle/watcher.js";
9
13
  import { moodBus } from "../mood-bus.js";
@@ -18,17 +22,74 @@ import { ISLAND_HTML } from "./island.js";
18
22
  import { MAIN_HTML } from "./lisa-html.js";
19
23
  import { OrchestratorHub, loadOrchestratorConfig } from "../integrations/hub.js";
20
24
  import { setCurrentHub } from "../integrations/current-hub.js";
21
- import { advise, formatDigest } from "../advisor/engine.js";
25
+ import { advise, dismissSuggestion, formatDigest } from "../advisor/engine.js";
22
26
  import { recordEvent } from "../orchestrator/journal.js";
23
27
  import { buildRecap, formatRecap } from "../orchestrator/recap.js";
24
28
  import { captureScreenshot, captureSupported } from "../vision/capture.js";
25
29
  import { transcribeAudio } from "../voice/transcribe.js";
26
30
  import { polishDictation } from "../voice/dictation.js";
31
+ import { listGrants, grant, revoke, revokeAll, isGranted, SENSE_SIGNALS, SIGNAL_DESCRIPTIONS } from "../consent/store.js";
32
+ import { signalAgentTool } from "../tools/signal_agent.js";
33
+ import { managedRegistry } from "../agents/managed.js";
34
+ import { ptyRegistry, ptyEnabled } from "../agents/pty.js";
35
+ import { liveClaudeSessionIds } from "../integrations/claude-code/liveness.js";
36
+ import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js";
37
+ import { loadControlPolicy, saveControlPolicy } from "../control/policy.js";
38
+ import { mintDevice, verifyDeviceToken, touchDevice, listDevices, revokeDevice } from "./devices.js";
39
+ import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs } from "./push.js";
40
+ import { SenseService } from "../sense/service.js";
41
+ import { ScreenSource } from "../sense/screen.js";
42
+ import { VoiceSource } from "../sense/voice.js";
43
+ import { appendSenseEvent, readSenseEvents } from "../sense/log.js";
27
44
  import { loadScreenAdvisorConfig, saveScreenAdvisorConfig, normalizeConfig, analyzeScreenshot, } from "../screen_advisor/engine.js";
28
45
  import os from "node:os";
29
46
  import { LISA_HOME } from "../paths.js";
30
47
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
48
  const ASSETS_DIR = path.join(__dirname, "assets");
49
+ /** True for loopback peer/bind addresses (v4, v6, and v4-mapped-v6 forms). */
50
+ export function isLoopbackAddress(addr) {
51
+ const a = addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
52
+ return a === "::1" || a === "localhost" || a.startsWith("127.");
53
+ }
54
+ function timingSafeEqualStr(a, b) {
55
+ // Hash both sides so the comparison is constant-time regardless of length.
56
+ const ha = crypto.createHash("sha256").update(a).digest();
57
+ const hb = crypto.createHash("sha256").update(b).digest();
58
+ return crypto.timingSafeEqual(ha, hb);
59
+ }
60
+ /**
61
+ * The web auth decision (the unit the red-team script + tests target): a request
62
+ * is authorized iff it's loopback (the local user) OR it presents the correct
63
+ * LISA_WEB_TOKEN. No token configured ⇒ no non-loopback request can pass. Pure.
64
+ */
65
+ export function isRequestAuthorized(remoteAddr, webToken, presented) {
66
+ if (isLoopbackAddress(remoteAddr))
67
+ return true;
68
+ return !!webToken && !!presented && timingSafeEqualStr(presented, webToken);
69
+ }
70
+ /**
71
+ * Extract a presented web token from Authorization header, lisa_token cookie,
72
+ * or ?token= query param (the query form exists so a phone can bootstrap the
73
+ * cookie by opening http://host:5757/?token=...).
74
+ */
75
+ function presentedToken(req, url) {
76
+ const auth = req.headers.authorization;
77
+ if (auth?.startsWith("Bearer "))
78
+ return auth.slice("Bearer ".length).trim();
79
+ const cookies = req.headers.cookie ?? "";
80
+ const m = /(?:^|;\s*)lisa_token=([^;]+)/.exec(cookies);
81
+ if (m)
82
+ return decodeURIComponent(m[1]);
83
+ try {
84
+ const q = new URL(url, "http://localhost").searchParams.get("token");
85
+ if (q)
86
+ return q;
87
+ }
88
+ catch {
89
+ /* fall through */
90
+ }
91
+ return null;
92
+ }
32
93
  async function resumeOrCreateWebSession(model) {
33
94
  const lastId = await readActiveWebSession();
34
95
  if (lastId) {
@@ -62,6 +123,14 @@ async function resumeOrCreateWebSession(model) {
62
123
  return s;
63
124
  }
64
125
  export async function startWebServer(opts) {
126
+ const host = opts.host ?? "127.0.0.1";
127
+ const webToken = process.env.LISA_WEB_TOKEN?.trim() || null;
128
+ if (!isLoopbackAddress(host) && !webToken) {
129
+ throw new Error(`refusing to bind ${host} without LISA_WEB_TOKEN — every endpoint drives a ` +
130
+ `full-tool agent on this machine. Set LISA_WEB_TOKEN (e.g. ` +
131
+ `\`LISA_WEB_TOKEN=$(openssl rand -hex 24)\` in ~/.lisa/config.env), then open ` +
132
+ `http://<host>:${opts.port}/?token=<value> from the remote device.`);
133
+ }
65
134
  const snapshot = await buildSystemPromptSnapshot();
66
135
  const initialFingerprint = await getPromptFingerprint();
67
136
  // Per-process hot-reload cache for the web server: same shape as cli's
@@ -100,6 +169,13 @@ export async function startWebServer(opts) {
100
169
  const { messages: savedMessages } = await session.readMessagePage(0, 9999);
101
170
  const history = [...savedMessages];
102
171
  const abort = new AbortController();
172
+ const hooks = opts.hooks ?? [];
173
+ // Serialize chat turns. The whole process shares ONE history array and ONE
174
+ // session file — two tabs (or a tab + the island) POSTing /chat concurrently
175
+ // would run two agents against the same history reference and clobber each
176
+ // other's `history.length = 0; history.push(...)` at the end. Same model as
177
+ // the channels router's per-thread busy+queue, with a promise chain.
178
+ let chatChain = Promise.resolve();
103
179
  // ── Persistent /events SSE subscribers (mood + idle broadcasts) ─────
104
180
  const eventClients = new Set();
105
181
  const broadcast = (event) => {
@@ -127,11 +203,15 @@ export async function startWebServer(opts) {
127
203
  const hub = new OrchestratorHub(orchestratorCfg, {
128
204
  log: (msg) => console.error(msg),
129
205
  });
206
+ // Operational push: notify subscribed phones on agent done/error/permission +
207
+ // Reve idle messages. Opt-in, ntfy by default (apns is a stub). See push.ts.
208
+ const pushBridge = new PushBridge({ log: (m) => console.error(m) });
130
209
  hub.on("update", (session) => {
131
210
  // L6 — record the transition in the orchestrator journal so the
132
211
  // cross-agent recap can answer "what happened while I was away?" even for
133
212
  // sessions that have since ended. (Dedups consecutive same-state events.)
134
213
  recordEvent(session);
214
+ pushBridge.onAgentUpdate(session);
135
215
  // New generalized event for the multi-agent UI.
136
216
  broadcast({ type: "agent_session_update", ...session });
137
217
  // Back-compat: the island still listens for claude_session_update as a
@@ -159,6 +239,7 @@ export async function startWebServer(opts) {
159
239
  // card — pull-friendly, not an interrupt. lastIdleMessage is assigned
160
240
  // inside the async callback (runs after all locals init; no TDZ issue).
161
241
  const ADVISE_INTERVAL_MS = 5 * 60_000;
242
+ let lastAdvisorSuggestions = null;
162
243
  const adviseTimer = setInterval(() => {
163
244
  void (async () => {
164
245
  try {
@@ -172,6 +253,19 @@ export async function startWebServer(opts) {
172
253
  const at = new Date().toISOString();
173
254
  lastIdleMessage = { text, at };
174
255
  broadcast({ type: "idle_message", text, at, source: "advisor" });
256
+ pushBridge.onIdleMessage(text);
257
+ // Structured twin of the digest: same suggestions with id / urgency /
258
+ // action attached so the island can render per-suggestion buttons
259
+ // (act → prefill chat, ✕ → dismiss feeds the learning loop).
260
+ const suggestions = surface.map((s) => ({
261
+ id: s.id,
262
+ category: s.category,
263
+ urgency: s.urgency,
264
+ text: s.text,
265
+ action: s.action ?? null,
266
+ }));
267
+ lastAdvisorSuggestions = { suggestions, at };
268
+ broadcast({ type: "advisor_suggestions", suggestions, at });
175
269
  console.error(`[advisor] surfaced ${surface.length} suggestion(s)`);
176
270
  }
177
271
  catch (err) {
@@ -193,7 +287,11 @@ export async function startWebServer(opts) {
193
287
  let screenTimer = null;
194
288
  let screenTickRunning = false;
195
289
  const screenTick = async () => {
196
- if (screenTickRunning || !screenCfg.enabled || !captureSupported())
290
+ // S2: a screenshot model IS screen capture, so it requires the `screen`
291
+ // consent grant (re-checked each tick so revoke-all stops it within one
292
+ // interval) IN ADDITION to the advisor's own enabled flag. This unifies all
293
+ // screen capture under the consent framework (FOUNDATIONS §1).
294
+ if (screenTickRunning || !screenCfg.enabled || !captureSupported() || !isGranted("screen"))
197
295
  return;
198
296
  screenTickRunning = true;
199
297
  try {
@@ -237,7 +335,25 @@ export async function startWebServer(opts) {
237
335
  restartScreenTimer();
238
336
  if (screenCfg.enabled) {
239
337
  console.error(`[screen-advisor] enabled — every ${screenCfg.intervalMinutes}m`);
338
+ if (!isGranted("screen")) {
339
+ console.error("[screen-advisor] waiting on `screen` consent — grant it (`lisa consent grant screen`) to start capturing.");
340
+ }
240
341
  }
342
+ // ── Sense ambient sources (S2) ──────────────────────────────────────
343
+ // The resident loop owns consent-gated ambient sources. ScreenSource ticks
344
+ // continuously but captures NOTHING until `screen` is granted (it re-checks
345
+ // each tick), so this is a no-op by default. On-change foreground-app events
346
+ // are distilled to the bounded sense log; nothing raw is persisted.
347
+ const senseService = new SenseService();
348
+ // VoiceSource is event-driven (fed by the transcribe endpoint when `voice` is
349
+ // granted); ScreenSource polls. Both no-op until their signal is granted.
350
+ const voiceSource = new VoiceSource();
351
+ senseService.register(new ScreenSource());
352
+ senseService.register(voiceSource);
353
+ void senseService.start((e) => {
354
+ appendSenseEvent(e);
355
+ broadcast({ type: "sense_event", ...e });
356
+ });
241
357
  // ── Island unread tracking (Phase 1 of MAC_ISLAND_PLAN) ─────────────
242
358
  // The island widget caches "last idle_message" so a fresh tab opening
243
359
  // mid-conversation knows there's something to read. Cleared via
@@ -280,6 +396,7 @@ export async function startWebServer(opts) {
280
396
  });
281
397
  lastIdleMessage = { text: result.text, at: startedAt };
282
398
  broadcast({ type: "idle_message", text: result.text, at: startedAt });
399
+ pushBridge.onIdleMessage(result.text);
283
400
  }
284
401
  }
285
402
  catch (err) {
@@ -296,14 +413,65 @@ export async function startWebServer(opts) {
296
413
  }
297
414
  const server = http.createServer(async (req, res) => {
298
415
  const url = req.url ?? "/";
299
- if (req.method === "GET" && url === "/") {
416
+ // ── Auth gate ────────────────────────────────────────────────────────
417
+ // Loopback callers are the local user — no token needed (the default
418
+ // 127.0.0.1 bind means this is the only case unless the user opted in
419
+ // via --host). Anything else must present LISA_WEB_TOKEN; /chat drives a
420
+ // full-tool agent and /api/vision/capture grabs the screen, so an
421
+ // unauthenticated non-loopback request is a remote-code-execution hole.
422
+ const remoteAddr = req.socket.remoteAddress ?? "";
423
+ if (!isLoopbackAddress(remoteAddr)) {
424
+ const presented = webToken ? presentedToken(req, url) : null;
425
+ // Authorized by the global LISA_WEB_TOKEN OR a non-revoked per-device token.
426
+ let authed = isRequestAuthorized(remoteAddr, webToken, presented);
427
+ if (!authed && presented) {
428
+ const device = verifyDeviceToken(presented);
429
+ if (device) {
430
+ authed = true;
431
+ touchDevice(device.id);
432
+ }
433
+ }
434
+ if (!authed) {
435
+ res.writeHead(401, { "content-type": "text/plain" });
436
+ res.end("unauthorized");
437
+ return;
438
+ }
439
+ // Token arrived via query param (first open from a phone): pin it as a
440
+ // cookie so subsequent same-origin fetch/EventSource calls authenticate.
441
+ // presented is non-null here — isRequestAuthorized returned true for a
442
+ // non-loopback caller, which requires a matching token.
443
+ if (presented && !req.headers.cookie?.includes("lisa_token=")) {
444
+ res.setHeader("set-cookie", `lisa_token=${encodeURIComponent(presented)}; HttpOnly; SameSite=Strict; Path=/`);
445
+ }
446
+ }
447
+ // Per-request gate for high-risk control actions from REMOTE callers. The Mac
448
+ // owner (loopback) is never gated; a remote (token) device may take only what
449
+ // the Mac-side policy permits. denyRemote() writes the 403 and returns true
450
+ // when blocked, so call sites read `if (denyRemote(...)) return;`. See
451
+ // src/control/policy.ts (default: control own agents yes, adopt external no).
452
+ const remoteControlAllowed = (need) => {
453
+ if (isLoopbackAddress(remoteAddr))
454
+ return true;
455
+ const pol = loadControlPolicy();
456
+ return need === "adoptExternal" ? pol.remoteAdoptExternal : pol.remoteControl;
457
+ };
458
+ const denyRemote = (need) => {
459
+ if (remoteControlAllowed(need))
460
+ return false;
461
+ res.writeHead(403, { "content-type": "text/plain" });
462
+ res.end(need === "adoptExternal"
463
+ ? "adopting external sessions from a remote device is disabled — enable remoteAdoptExternal on the Mac (POST /api/control/policy from localhost)"
464
+ : "remote control is disabled — enable remoteControl on the Mac (POST /api/control/policy from localhost)");
465
+ return true;
466
+ };
467
+ if (req.method === "GET" && (url === "/" || url.startsWith("/?"))) {
300
468
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
301
469
  res.end(MAIN_HTML);
302
470
  return;
303
471
  }
304
472
  // Island widget — designed to be opened in a tiny browser window
305
473
  // (Arc, Vivaldi PWA, Safari split). See docs/MAC_ISLAND_PLAN.md.
306
- if (req.method === "GET" && url === "/island") {
474
+ if (req.method === "GET" && (url === "/island" || url.startsWith("/island?"))) {
307
475
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
308
476
  res.end(ISLAND_HTML);
309
477
  return;
@@ -454,6 +622,10 @@ export async function startWebServer(opts) {
454
622
  await fs.writeFile(tmp, Buffer.from(payload.data, "base64"));
455
623
  console.error(`[voice] transcribing ${Buffer.from(payload.data, "base64").length} bytes (${ext})`);
456
624
  const transcript = await transcribeAudio({ audioPath: tmp });
625
+ // S2-voice: when `voice` is granted, distill this push-to-talk transcript
626
+ // into the ambient sense log (PII-redacted, no audio). No-op otherwise,
627
+ // so dictation works unchanged when voice consent is off.
628
+ voiceSource.ingest(transcript);
457
629
  // Dictation mode (Typeless-equivalent): polish the raw transcript into
458
630
  // the clean text the speaker intended (filler/repetition removed,
459
631
  // self-corrections applied, punctuation + formatting), to drop into the
@@ -490,13 +662,549 @@ export async function startWebServer(opts) {
490
662
  res.end(JSON.stringify({ ok: true }));
491
663
  return;
492
664
  }
665
+ // Latest advisor suggestions, for a freshly opened island.
666
+ if (req.method === "GET" && url === "/api/advisor/latest") {
667
+ res.writeHead(200, { "content-type": "application/json" });
668
+ res.end(JSON.stringify(lastAdvisorSuggestions ?? { suggestions: [], at: null }));
669
+ return;
670
+ }
671
+ // ✕ on an advisor suggestion: persist the dismissal so the category
672
+ // down-weights over time ("learns to shut up"), and drop it from the
673
+ // cached card so a refreshed island doesn't resurrect it.
674
+ if (req.method === "POST" && url === "/api/advisor/dismiss") {
675
+ let dBody = "";
676
+ for await (const chunk of req)
677
+ dBody += chunk.toString("utf8");
678
+ let payload;
679
+ try {
680
+ payload = JSON.parse(dBody || "{}");
681
+ }
682
+ catch {
683
+ res.writeHead(400, { "content-type": "text/plain" });
684
+ res.end("bad json");
685
+ return;
686
+ }
687
+ if (typeof payload.id !== "string" || typeof payload.category !== "string") {
688
+ res.writeHead(400, { "content-type": "text/plain" });
689
+ res.end("id and category required");
690
+ return;
691
+ }
692
+ try {
693
+ await dismissSuggestion(payload.id, payload.category);
694
+ }
695
+ catch (err) {
696
+ res.writeHead(500, { "content-type": "text/plain" });
697
+ res.end(err.message);
698
+ return;
699
+ }
700
+ if (lastAdvisorSuggestions) {
701
+ lastAdvisorSuggestions = {
702
+ ...lastAdvisorSuggestions,
703
+ suggestions: lastAdvisorSuggestions.suggestions.filter((s) => s.id !== payload.id),
704
+ };
705
+ }
706
+ res.writeHead(200, { "content-type": "application/json" });
707
+ res.end(JSON.stringify({ ok: true }));
708
+ return;
709
+ }
710
+ // ── Consent (FOUNDATIONS §1) ────────────────────────────────────────
711
+ // The island's "always visible + one-tap stop" surface. GET lists every
712
+ // sense signal + status; the POSTs grant/revoke/stop-all. Sources gate on
713
+ // isGranted() server-side regardless — this is control, not the gate.
714
+ if (req.method === "GET" && url === "/api/consent") {
715
+ const grants = listGrants().map((g) => ({
716
+ ...g,
717
+ description: SIGNAL_DESCRIPTIONS[g.signal] ?? "",
718
+ }));
719
+ res.writeHead(200, { "content-type": "application/json" });
720
+ res.end(JSON.stringify({ grants }));
721
+ return;
722
+ }
723
+ // D4b — list/cancel LISA's OWN dispatched agents over HTTP. Behind the auth
724
+ // gate like every endpoint (loopback or token). Reuses signal_agent, so it
725
+ // can ONLY touch dispatch-ledger pids — never an arbitrary process.
726
+ if (req.method === "POST" && url === "/api/agent/signal") {
727
+ let sigBody = "";
728
+ for await (const chunk of req)
729
+ sigBody += chunk.toString("utf8");
730
+ let payload;
731
+ try {
732
+ payload = JSON.parse(sigBody || "{}");
733
+ }
734
+ catch {
735
+ res.writeHead(400, { "content-type": "text/plain" });
736
+ res.end("bad json");
737
+ return;
738
+ }
739
+ const action = payload.action === "list" ? "list" : payload.action === "cancel" ? "cancel" : null;
740
+ if (!action) {
741
+ res.writeHead(400, { "content-type": "text/plain" });
742
+ res.end("action must be 'list' or 'cancel'");
743
+ return;
744
+ }
745
+ if (action === "cancel" && denyRemote("control"))
746
+ return;
747
+ const result = await signalAgentTool.execute({
748
+ action,
749
+ target: typeof payload.target === "string" ? payload.target : undefined,
750
+ force: payload.force === true,
751
+ }, { cwd: process.cwd(), signal: new AbortController().signal, log: () => { } });
752
+ res.writeHead(200, { "content-type": "application/json" });
753
+ res.end(JSON.stringify({ ok: true, result }));
754
+ return;
755
+ }
756
+ // ── Managed agents (control plane) ──────────────────────────────────
757
+ // LISA's own controllable agents: start a task, send follow-ups, cancel,
758
+ // and approve/deny each mutating tool. Behind the auth gate like every
759
+ // endpoint. They appear in the roster via the "managed" hub observer.
760
+ // Device pairing: mint a per-device token (loopback-only — the Mac owner
761
+ // pairs a phone by showing the returned token as a QR). The raw token is
762
+ // returned ONCE; only its hash is stored. The phone then authenticates with
763
+ // it like the global token (Bearer / ?token= / cookie).
764
+ if (req.method === "POST" && url === "/api/pair/start") {
765
+ if (!isLoopbackAddress(remoteAddr)) {
766
+ res.writeHead(403, { "content-type": "text/plain" });
767
+ res.end("pairing can only be started from the Mac (localhost)");
768
+ return;
769
+ }
770
+ let prBody = "";
771
+ for await (const chunk of req)
772
+ prBody += chunk.toString("utf8");
773
+ let payload = {};
774
+ try {
775
+ payload = prBody ? JSON.parse(prBody) : {};
776
+ }
777
+ catch { /* tolerate */ }
778
+ const name = typeof payload.name === "string" ? payload.name : "device";
779
+ const platform = typeof payload.platform === "string" ? payload.platform : "ios";
780
+ const { id, token, device } = mintDevice(name, platform);
781
+ res.writeHead(200, { "content-type": "application/json" });
782
+ res.end(JSON.stringify({ ok: true, id, token, port: opts.port, device }));
783
+ return;
784
+ }
785
+ if (req.method === "GET" && url === "/api/devices") {
786
+ res.writeHead(200, { "content-type": "application/json" });
787
+ res.end(JSON.stringify({ devices: listDevices() }));
788
+ return;
789
+ }
790
+ if (req.method === "POST" && url === "/api/devices/revoke") {
791
+ if (!isLoopbackAddress(remoteAddr)) {
792
+ res.writeHead(403, { "content-type": "text/plain" });
793
+ res.end("device revocation is a Mac-owner action (localhost only)");
794
+ return;
795
+ }
796
+ let rvBody = "";
797
+ for await (const chunk of req)
798
+ rvBody += chunk.toString("utf8");
799
+ let payload = {};
800
+ try {
801
+ payload = rvBody ? JSON.parse(rvBody) : {};
802
+ }
803
+ catch { /* tolerate */ }
804
+ const removed = revokeDevice(typeof payload.id === "string" ? payload.id : "");
805
+ res.writeHead(removed ? 200 : 404, { "content-type": "application/json" });
806
+ res.end(JSON.stringify({ ok: removed }));
807
+ return;
808
+ }
809
+ // Push registration: a phone registers its own delivery target (ntfy topic or
810
+ // APNs token) + prefs — authed (the device does this remotely). Low-sensitivity
811
+ // metadata only; see push.ts.
812
+ if (req.method === "POST" && url === "/api/push/register") {
813
+ let puBody = "";
814
+ for await (const chunk of req)
815
+ puBody += chunk.toString("utf8");
816
+ let payload = {};
817
+ try {
818
+ payload = puBody ? JSON.parse(puBody) : {};
819
+ }
820
+ catch { /* tolerate */ }
821
+ if (typeof payload.target !== "string" || !payload.target.trim()) {
822
+ res.writeHead(400, { "content-type": "text/plain" });
823
+ res.end("target required (ntfy topic or apns token)");
824
+ return;
825
+ }
826
+ const sub = registerPush({
827
+ kind: typeof payload.kind === "string" ? payload.kind : "ntfy",
828
+ target: payload.target,
829
+ server: typeof payload.server === "string" ? payload.server : undefined,
830
+ prefs: payload.prefs,
831
+ });
832
+ res.writeHead(200, { "content-type": "application/json" });
833
+ res.end(JSON.stringify({ ok: true, subscription: sub }));
834
+ return;
835
+ }
836
+ if (req.method === "POST" && url === "/api/push/unregister") {
837
+ let puBody = "";
838
+ for await (const chunk of req)
839
+ puBody += chunk.toString("utf8");
840
+ let payload = {};
841
+ try {
842
+ payload = puBody ? JSON.parse(puBody) : {};
843
+ }
844
+ catch { /* tolerate */ }
845
+ const key = typeof payload.id === "string" ? payload.id : typeof payload.target === "string" ? payload.target : "";
846
+ const removed = unregisterPush(key);
847
+ res.writeHead(removed ? 200 : 404, { "content-type": "application/json" });
848
+ res.end(JSON.stringify({ ok: removed }));
849
+ return;
850
+ }
851
+ if (req.method === "GET" && url === "/api/push/list") {
852
+ res.writeHead(200, { "content-type": "application/json" });
853
+ res.end(JSON.stringify({ subscriptions: listPush() }));
854
+ return;
855
+ }
856
+ if (req.method === "POST" && url === "/api/push/prefs") {
857
+ let puBody = "";
858
+ for await (const chunk of req)
859
+ puBody += chunk.toString("utf8");
860
+ let payload = {};
861
+ try {
862
+ payload = puBody ? JSON.parse(puBody) : {};
863
+ }
864
+ catch { /* tolerate */ }
865
+ const sub = typeof payload.id === "string" ? setPushPrefs(payload.id, payload.prefs ?? {}) : null;
866
+ res.writeHead(sub ? 200 : 404, { "content-type": "application/json" });
867
+ res.end(JSON.stringify(sub ? { ok: true, subscription: sub } : { ok: false }));
868
+ return;
869
+ }
870
+ // Remote-control policy: GET (any authed caller) reports it; POST sets it,
871
+ // but only from localhost (the Mac owner) — like the API-key save.
872
+ if (req.method === "GET" && url === "/api/control/policy") {
873
+ res.writeHead(200, { "content-type": "application/json" });
874
+ res.end(JSON.stringify(loadControlPolicy()));
875
+ return;
876
+ }
877
+ if (req.method === "POST" && url === "/api/control/policy") {
878
+ if (!isLoopbackAddress(remoteAddr)) {
879
+ res.writeHead(403, { "content-type": "text/plain" });
880
+ res.end("control policy can only be changed from the Mac (localhost)");
881
+ return;
882
+ }
883
+ let cpBody = "";
884
+ for await (const chunk of req)
885
+ cpBody += chunk.toString("utf8");
886
+ let payload;
887
+ try {
888
+ payload = JSON.parse(cpBody || "{}");
889
+ }
890
+ catch {
891
+ res.writeHead(400, { "content-type": "text/plain" });
892
+ res.end("bad json");
893
+ return;
894
+ }
895
+ try {
896
+ const saved = saveControlPolicy({ ...loadControlPolicy(), ...payload });
897
+ res.writeHead(200, { "content-type": "application/json" });
898
+ res.end(JSON.stringify({ ok: true, ...saved }));
899
+ }
900
+ catch (err) {
901
+ res.writeHead(500, { "content-type": "text/plain" });
902
+ res.end(err.message);
903
+ }
904
+ return;
905
+ }
906
+ // Structured view of the agents LISA dispatched fire-and-forget (the ledger).
907
+ // Complements /api/agent/signal's action:"list" (which returns prose): this is
908
+ // JSON for clients (the iOS roster). Structural only — never the captured log.
909
+ if (req.method === "GET" && url === "/api/dispatch/list") {
910
+ const dispatches = listRecentDispatches().map((e) => toDispatchView(e, isAlive(e.pid)));
911
+ res.writeHead(200, { "content-type": "application/json" });
912
+ res.end(JSON.stringify({ dispatches }));
913
+ return;
914
+ }
915
+ // Status + captured-output tail of ONE dispatched agent. The tail is raw
916
+ // stdout (potentially sensitive), so it's behind the remote-control gate
917
+ // (loopback always; remote per policy). ?id=<dispatch id>.
918
+ if (req.method === "GET" && url.startsWith("/api/dispatch/status")) {
919
+ if (denyRemote("control"))
920
+ return;
921
+ const id = new URL(url, "http://localhost").searchParams.get("id") ?? "";
922
+ const entry = id ? listRecentDispatches().find((e) => e.id === id) : undefined;
923
+ if (!entry) {
924
+ res.writeHead(id ? 404 : 400, { "content-type": "application/json" });
925
+ res.end(JSON.stringify({ ok: false, error: id ? "no such dispatch" : "id required" }));
926
+ return;
927
+ }
928
+ res.writeHead(200, { "content-type": "application/json" });
929
+ res.end(JSON.stringify({
930
+ ok: true,
931
+ ...toDispatchView(entry, isAlive(entry.pid)),
932
+ tail: readDispatchOutput(entry, 4000),
933
+ }));
934
+ return;
935
+ }
936
+ if (req.method === "POST" && url === "/api/agents/managed/start") {
937
+ if (denyRemote("control"))
938
+ return;
939
+ let mBody = "";
940
+ for await (const chunk of req)
941
+ mBody += chunk.toString("utf8");
942
+ let payload;
943
+ try {
944
+ payload = JSON.parse(mBody || "{}");
945
+ }
946
+ catch {
947
+ res.writeHead(400, { "content-type": "text/plain" });
948
+ res.end("bad json");
949
+ return;
950
+ }
951
+ if (typeof payload.task !== "string" || !payload.task.trim()) {
952
+ res.writeHead(400, { "content-type": "text/plain" });
953
+ res.end("task required");
954
+ return;
955
+ }
956
+ const cwd = typeof payload.cwd === "string" && payload.cwd.startsWith("/") ? payload.cwd : process.cwd();
957
+ // A managed agent doesn't control other agents — drop dispatch/signal.
958
+ const tools = opts.tools.filter((t) => t.name !== "dispatch_agent" && t.name !== "signal_agent");
959
+ const systemPrompt = `You are a delegated agent working in ${cwd}, launched by the user through Lisa. ` +
960
+ `Complete the user's task using the available tools, then report what you did concisely. ` +
961
+ `Mutating actions (writes, shell, etc.) pause for the user's approval — keep going after each decision.`;
962
+ const view = managedRegistry.start({
963
+ task: payload.task,
964
+ cwd,
965
+ systemPrompt,
966
+ tools,
967
+ model: typeof payload.model === "string" ? payload.model : opts.model,
968
+ });
969
+ res.writeHead(200, { "content-type": "application/json" });
970
+ res.end(JSON.stringify({ ok: true, agent: view }));
971
+ return;
972
+ }
973
+ if (req.method === "POST" && url.startsWith("/api/agents/managed/")) {
974
+ if (denyRemote("control"))
975
+ return;
976
+ const rest = url.slice("/api/agents/managed/".length);
977
+ const slash = rest.indexOf("/");
978
+ const id = slash >= 0 ? rest.slice(0, slash) : rest;
979
+ const action = slash >= 0 ? rest.slice(slash + 1) : "";
980
+ let mBody = "";
981
+ for await (const chunk of req)
982
+ mBody += chunk.toString("utf8");
983
+ let payload = {};
984
+ try {
985
+ payload = mBody ? JSON.parse(mBody) : {};
986
+ }
987
+ catch { /* tolerate empty/none */ }
988
+ let ok = false;
989
+ if (action === "send" && typeof payload.text === "string")
990
+ ok = managedRegistry.send(id, payload.text);
991
+ else if (action === "cancel")
992
+ ok = managedRegistry.cancel(id);
993
+ else if (action === "approve")
994
+ ok = managedRegistry.decide(id, payload.allow !== false);
995
+ else {
996
+ res.writeHead(400, { "content-type": "text/plain" });
997
+ res.end("action must be send|cancel|approve");
998
+ return;
999
+ }
1000
+ res.writeHead(ok ? 200 : 404, { "content-type": "application/json" });
1001
+ res.end(JSON.stringify({ ok }));
1002
+ return;
1003
+ }
1004
+ // ── PTY agents (Stage C spike, off unless LISA_PTY_AGENTS=1) ─────────
1005
+ // Real `claude`/`codex` CLIs LISA spawns under a pseudo-terminal: it types
1006
+ // your task + follow-ups and can read the terminal tail. Behind the same
1007
+ // auth gate; 503 when the spike flag is off / node-pty is absent.
1008
+ if (req.method === "POST" && url === "/api/agents/pty/start") {
1009
+ let pBody = "";
1010
+ for await (const chunk of req)
1011
+ pBody += chunk.toString("utf8");
1012
+ let payload;
1013
+ try {
1014
+ payload = JSON.parse(pBody || "{}");
1015
+ }
1016
+ catch {
1017
+ res.writeHead(400, { "content-type": "text/plain" });
1018
+ res.end("bad json");
1019
+ return;
1020
+ }
1021
+ if (!ptyEnabled()) {
1022
+ res.writeHead(503, { "content-type": "text/plain" });
1023
+ res.end("PTY agents are disabled — set LISA_PTY_AGENTS=1 to enable this spike");
1024
+ return;
1025
+ }
1026
+ const agent = typeof payload.agent === "string" && payload.agent.trim() ? payload.agent : "claude";
1027
+ const resumeSessionId = typeof payload.resumeSessionId === "string" && payload.resumeSessionId ? payload.resumeSessionId : undefined;
1028
+ // Adopting an external session is the highest-risk control action — gate it
1029
+ // behind remoteAdoptExternal; starting a fresh agent is ordinary control.
1030
+ if (denyRemote(resumeSessionId ? "adoptExternal" : "control"))
1031
+ return;
1032
+ // Adopting an existing session needs no task (it continues the convo); a
1033
+ // fresh agent does. Guard: never resume a session that's currently live.
1034
+ if (!resumeSessionId && (typeof payload.task !== "string" || !payload.task.trim())) {
1035
+ res.writeHead(400, { "content-type": "text/plain" });
1036
+ res.end("task required");
1037
+ return;
1038
+ }
1039
+ if (resumeSessionId && liveClaudeSessionIds().has(resumeSessionId)) {
1040
+ res.writeHead(409, { "content-type": "text/plain" });
1041
+ res.end("that session is currently live (open in the app/terminal) — close it first; resuming a live session would corrupt its transcript");
1042
+ return;
1043
+ }
1044
+ const cwd = typeof payload.cwd === "string" && payload.cwd.startsWith("/") ? payload.cwd : process.cwd();
1045
+ try {
1046
+ const view = await ptyRegistry.start({
1047
+ agent,
1048
+ task: typeof payload.task === "string" ? payload.task : "",
1049
+ cwd,
1050
+ resumeSessionId,
1051
+ });
1052
+ res.writeHead(200, { "content-type": "application/json" });
1053
+ res.end(JSON.stringify({ ok: true, agent: view }));
1054
+ }
1055
+ catch (err) {
1056
+ res.writeHead(503, { "content-type": "text/plain" });
1057
+ res.end(err.message);
1058
+ }
1059
+ return;
1060
+ }
1061
+ if (req.method === "GET" && url.startsWith("/api/agents/pty/") && url.endsWith("/output")) {
1062
+ if (denyRemote("control"))
1063
+ return;
1064
+ const id = url.slice("/api/agents/pty/".length, -"/output".length);
1065
+ const out = ptyRegistry.output(decodeURIComponent(id));
1066
+ if (out === null) {
1067
+ res.writeHead(404, { "content-type": "application/json" });
1068
+ res.end(JSON.stringify({ ok: false }));
1069
+ return;
1070
+ }
1071
+ res.writeHead(200, { "content-type": "application/json" });
1072
+ res.end(JSON.stringify({ ok: true, output: out }));
1073
+ return;
1074
+ }
1075
+ // Live attach stream for a PTY agent: the current output tail (snapshot), then
1076
+ // each new ANSI-stripped chunk as it arrives. Drives `lisa agents pty`
1077
+ // (adopt-at-launch) and a remote live-output view. Same auth gate; 404 if unknown.
1078
+ if (req.method === "GET" && url.startsWith("/api/agents/pty/") && url.endsWith("/stream")) {
1079
+ if (denyRemote("control"))
1080
+ return;
1081
+ const id = decodeURIComponent(url.slice("/api/agents/pty/".length, -"/stream".length));
1082
+ const initial = ptyRegistry.output(id);
1083
+ if (initial === null) {
1084
+ res.writeHead(404, { "content-type": "application/json" });
1085
+ res.end(JSON.stringify({ ok: false }));
1086
+ return;
1087
+ }
1088
+ res.writeHead(200, {
1089
+ "content-type": "text/event-stream",
1090
+ "cache-control": "no-cache",
1091
+ connection: "keep-alive",
1092
+ });
1093
+ res.write(`data: ${JSON.stringify({ type: "snapshot", text: initial })}\n\n`);
1094
+ const onOut = (e) => {
1095
+ if (e.id !== id)
1096
+ return;
1097
+ try {
1098
+ res.write(`data: ${JSON.stringify({ type: "chunk", text: e.chunk })}\n\n`);
1099
+ }
1100
+ catch {
1101
+ /* dead connection — cleaned up on close */
1102
+ }
1103
+ };
1104
+ // End the stream when the agent finishes, so an attach client exits cleanly.
1105
+ const onUpdate = (v) => {
1106
+ if (v.id !== id || v.state !== "done")
1107
+ return;
1108
+ try {
1109
+ res.write(`data: ${JSON.stringify({ type: "end", state: v.state })}\n\n`);
1110
+ res.end();
1111
+ }
1112
+ catch {
1113
+ /* already closed */
1114
+ }
1115
+ };
1116
+ ptyRegistry.on("output", onOut);
1117
+ ptyRegistry.on("update", onUpdate);
1118
+ req.on("close", () => {
1119
+ ptyRegistry.off("output", onOut);
1120
+ ptyRegistry.off("update", onUpdate);
1121
+ });
1122
+ return;
1123
+ }
1124
+ if (req.method === "POST" && url.startsWith("/api/agents/pty/")) {
1125
+ if (denyRemote("control"))
1126
+ return;
1127
+ const rest = url.slice("/api/agents/pty/".length);
1128
+ const slash = rest.indexOf("/");
1129
+ const id = decodeURIComponent(slash >= 0 ? rest.slice(0, slash) : rest);
1130
+ const action = slash >= 0 ? rest.slice(slash + 1) : "";
1131
+ let pBody = "";
1132
+ for await (const chunk of req)
1133
+ pBody += chunk.toString("utf8");
1134
+ let payload = {};
1135
+ try {
1136
+ payload = pBody ? JSON.parse(pBody) : {};
1137
+ }
1138
+ catch { /* tolerate */ }
1139
+ let ok = false;
1140
+ if (action === "send" && typeof payload.text === "string")
1141
+ ok = ptyRegistry.send(id, payload.text);
1142
+ else if (action === "cancel")
1143
+ ok = ptyRegistry.cancel(id);
1144
+ else {
1145
+ res.writeHead(400, { "content-type": "text/plain" });
1146
+ res.end("action must be send|cancel");
1147
+ return;
1148
+ }
1149
+ res.writeHead(ok ? 200 : 404, { "content-type": "application/json" });
1150
+ res.end(JSON.stringify({ ok }));
1151
+ return;
1152
+ }
1153
+ // Recent ambient sense events, for the island's "recently sensed" list.
1154
+ if (req.method === "GET" && url === "/api/sense/recent") {
1155
+ const events = readSenseEvents().slice(-30).reverse(); // newest first
1156
+ res.writeHead(200, { "content-type": "application/json" });
1157
+ res.end(JSON.stringify({ events }));
1158
+ return;
1159
+ }
1160
+ if (req.method === "POST" && url === "/api/consent/revoke-all") {
1161
+ revokeAll();
1162
+ res.writeHead(200, { "content-type": "application/json" });
1163
+ res.end(JSON.stringify({ ok: true, grants: listGrants() }));
1164
+ return;
1165
+ }
1166
+ if (req.method === "POST" &&
1167
+ (url === "/api/consent/grant" || url === "/api/consent/revoke")) {
1168
+ let cBody = "";
1169
+ for await (const chunk of req)
1170
+ cBody += chunk.toString("utf8");
1171
+ let payload;
1172
+ try {
1173
+ payload = JSON.parse(cBody || "{}");
1174
+ }
1175
+ catch {
1176
+ res.writeHead(400, { "content-type": "text/plain" });
1177
+ res.end("bad json");
1178
+ return;
1179
+ }
1180
+ const signal = payload.signal;
1181
+ // Only the canonical sense signals are togglable from the UI.
1182
+ if (typeof signal !== "string" || !SENSE_SIGNALS.includes(signal)) {
1183
+ res.writeHead(400, { "content-type": "text/plain" });
1184
+ res.end(`signal must be one of: ${SENSE_SIGNALS.join(", ")}`);
1185
+ return;
1186
+ }
1187
+ if (url === "/api/consent/grant")
1188
+ grant(signal);
1189
+ else
1190
+ revoke(signal);
1191
+ res.writeHead(200, { "content-type": "application/json" });
1192
+ res.end(JSON.stringify({ ok: true, grants: listGrants() }));
1193
+ return;
1194
+ }
493
1195
  // Cross-agent session snapshot (O1). Returns every active session
494
1196
  // across all integrations, normalized. activity (Tier 2) is present
495
1197
  // when the integration runs at visibility ≥ "activity".
496
1198
  if (req.method === "GET" && url === "/api/agents/sessions") {
1199
+ // Mark idle claude sessions as adoptable: not currently live (no running
1200
+ // owner ⇒ `claude --resume` is safe) and not already a LISA-controlled row.
1201
+ const live = liveClaudeSessionIds();
497
1202
  const sessions = hub.list().map((s) => ({
498
1203
  ...s,
499
1204
  lastMtime: new Date(s.lastMtime).toISOString(),
1205
+ ...(s.agent === "claude-code" && !s.controllable && !live.has(s.sessionId)
1206
+ ? { resumable: true }
1207
+ : {}),
500
1208
  }));
501
1209
  res.writeHead(200, { "content-type": "application/json" });
502
1210
  res.end(JSON.stringify({ sessions }));
@@ -696,6 +1404,68 @@ self.addEventListener('fetch', (event) => {
696
1404
  }));
697
1405
  return;
698
1406
  }
1407
+ // ── Coding plans (CODING_PLANS Phase 5b): detect, show usage, pick a
1408
+ // delegation target. Detection is presence-only (no secrets read); the
1409
+ // POST writes LISA_CODING_PLAN, not the model — her own loop is unchanged.
1410
+ if (req.method === "GET" && url === "/api/plans") {
1411
+ const nowMs = Date.now();
1412
+ const sel = selectedPlan();
1413
+ const plans = detectPlans().map((p) => {
1414
+ const row = {
1415
+ id: p.id,
1416
+ label: p.label,
1417
+ cli: p.cli,
1418
+ detail: p.detail,
1419
+ available: p.available,
1420
+ loggedIn: p.loggedIn,
1421
+ mark: planMark(p),
1422
+ selected: p.id === sel,
1423
+ };
1424
+ if (p.available) {
1425
+ const u = planUsage(p.id, nowMs);
1426
+ if (u && (u.windowTokens || u.todayTokens))
1427
+ row.usage = formatUsage(u);
1428
+ }
1429
+ return row;
1430
+ });
1431
+ res.writeHead(200, { "content-type": "application/json" });
1432
+ res.end(JSON.stringify({ plans, selected: sel }));
1433
+ return;
1434
+ }
1435
+ if (req.method === "POST" && url === "/api/plans/select") {
1436
+ let body = "";
1437
+ for await (const chunk of req)
1438
+ body += chunk.toString("utf8");
1439
+ let payload;
1440
+ try {
1441
+ payload = JSON.parse(body || "{}");
1442
+ }
1443
+ catch {
1444
+ res.writeHead(400, { "content-type": "text/plain" });
1445
+ res.end("bad json");
1446
+ return;
1447
+ }
1448
+ const raw = typeof payload.plan === "string" ? payload.plan.trim().toLowerCase() : "";
1449
+ const planId = raw === "" || raw === "none"
1450
+ ? ""
1451
+ : (parsePlanRef(raw) ?? (PLAN_IDS.includes(raw) ? raw : null));
1452
+ if (planId === null) {
1453
+ res.writeHead(400, { "content-type": "text/plain" });
1454
+ res.end("unknown plan — use claude | codex | copilot | none");
1455
+ return;
1456
+ }
1457
+ try {
1458
+ await saveConfigEnv({ LISA_CODING_PLAN: planId });
1459
+ }
1460
+ catch (err) {
1461
+ res.writeHead(500, { "content-type": "text/plain" });
1462
+ res.end(err.message);
1463
+ return;
1464
+ }
1465
+ res.writeHead(200, { "content-type": "application/json" });
1466
+ res.end(JSON.stringify({ ok: true, selected: planId || null }));
1467
+ return;
1468
+ }
699
1469
  if (req.method === "GET" && url === "/api/config/status") {
700
1470
  res.writeHead(200, { "content-type": "application/json" });
701
1471
  res.end(JSON.stringify({
@@ -847,7 +1617,23 @@ self.addEventListener('fetch', (event) => {
847
1617
  let body = "";
848
1618
  for await (const chunk of req)
849
1619
  body += chunk.toString("utf8");
850
- const { message, files } = JSON.parse(body);
1620
+ let message;
1621
+ let files;
1622
+ try {
1623
+ // Every other endpoint guards its JSON.parse — this one used to be the
1624
+ // only one that didn't, and a malformed body left the socket hanging.
1625
+ const parsed = JSON.parse(body);
1626
+ if (typeof parsed.message !== "string" || !parsed.message.trim()) {
1627
+ throw new Error("missing message");
1628
+ }
1629
+ message = parsed.message;
1630
+ files = parsed.files;
1631
+ }
1632
+ catch (err) {
1633
+ res.writeHead(400, { "content-type": "application/json" });
1634
+ res.end(JSON.stringify({ error: `bad request: ${err.message}` }));
1635
+ return;
1636
+ }
851
1637
  // User just talked — reset the idle watcher.
852
1638
  try {
853
1639
  getIdleWatcher(60 * 60_000).tick();
@@ -863,64 +1649,92 @@ self.addEventListener('fetch', (event) => {
863
1649
  moodBus.on("mood", onMood);
864
1650
  // Send the current mood immediately so a fresh tab knows where to start.
865
1651
  send({ type: "mood", slug: moodBus.current() });
866
- try {
867
- // Use the freshest cached prompt for this chat. If soul / skills /
868
- // memory changed since the previous chat, rebuildPrompt() picks it up.
869
- const fresh = await rebuildPrompt();
870
- const result = await runAgent({
871
- provider: getProvider(),
872
- systemPrompt: fresh.text,
873
- tools: opts.tools,
874
- toolCtx: {
875
- cwd: process.cwd(),
876
- signal: abort.signal,
877
- log: () => { },
878
- },
879
- history,
880
- userMessage: message,
881
- userFiles: files,
882
- model: opts.model,
883
- thinking: opts.thinking,
884
- onEvent: (ev) => {
885
- if (ev.type === "text_delta" && ev.text)
886
- send({ type: "text", text: ev.text });
887
- if (ev.type === "tool_call_start")
888
- send({
889
- type: "tool_start",
890
- name: ev.toolName,
891
- input: ev.toolInput,
892
- });
893
- if (ev.type === "tool_call_end")
894
- send({
895
- type: "tool_end",
896
- name: ev.toolName,
897
- isError: ev.isError === true,
898
- resultPreview: typeof ev.toolResult === "string"
899
- ? ev.toolResult.slice(0, 200)
900
- : "",
901
- });
902
- if (ev.type === "system_prompt_rebuilt")
903
- send({ type: "soul_reload", message: ev.message ?? "" });
904
- if (ev.type === "error")
905
- send({ type: "error", message: ev.message ?? "" });
906
- },
907
- onMessagePersist: (m) => session.appendMessage(m),
908
- hotReload: {
909
- initialFingerprint: fresh.fingerprint,
910
- rebuild: rebuildPrompt,
911
- },
912
- });
913
- history.length = 0;
914
- history.push(...result.history);
915
- send({ type: "done" });
916
- }
917
- catch (err) {
918
- send({ type: "error", message: err.message });
919
- }
920
- finally {
921
- moodBus.off("mood", onMood);
922
- res.end();
923
- }
1652
+ const runTurn = async () => {
1653
+ try {
1654
+ // Use the freshest cached prompt for this chat. If soul / skills /
1655
+ // memory changed since the previous chat, rebuildPrompt() picks it up.
1656
+ const fresh = await rebuildPrompt();
1657
+ const result = await runAgent({
1658
+ provider: getProvider(),
1659
+ systemPrompt: fresh.text,
1660
+ tools: opts.tools,
1661
+ toolCtx: {
1662
+ cwd: process.cwd(),
1663
+ signal: abort.signal,
1664
+ log: () => { },
1665
+ },
1666
+ history,
1667
+ userMessage: message,
1668
+ userFiles: files,
1669
+ model: opts.model,
1670
+ thinking: opts.thinking,
1671
+ onEvent: (ev) => {
1672
+ if (ev.type === "text_delta" && ev.text)
1673
+ send({ type: "text", text: ev.text });
1674
+ if (ev.type === "tool_call_start")
1675
+ send({
1676
+ type: "tool_start",
1677
+ name: ev.toolName,
1678
+ input: ev.toolInput,
1679
+ });
1680
+ if (ev.type === "tool_call_end")
1681
+ send({
1682
+ type: "tool_end",
1683
+ name: ev.toolName,
1684
+ isError: ev.isError === true,
1685
+ resultPreview: typeof ev.toolResult === "string"
1686
+ ? ev.toolResult.slice(0, 200)
1687
+ : "",
1688
+ });
1689
+ if (ev.type === "system_prompt_rebuilt")
1690
+ send({ type: "soul_reload", message: ev.message ?? "" });
1691
+ if (ev.type === "error")
1692
+ send({ type: "error", message: ev.message ?? "" });
1693
+ },
1694
+ // Same plugin hook wiring as the CLI turn — PreToolUse can block,
1695
+ // PostToolUse can rewrite. (Was CLI-only; web tool calls bypassed
1696
+ // every configured hook.)
1697
+ preToolHook: hooks.length === 0 ? undefined : async (name, input) => {
1698
+ const r = await fireHooks("PreToolUse", hooks, { TOOL_NAME: name, TOOL_INPUT: JSON.stringify(input), SESSION_ID: session.id, LISA_HOME, CLAUDE_PROJECT_DIR: process.cwd() }, process.cwd());
1699
+ if (r.blocked.length > 0)
1700
+ return { block: r.blocked.join("; ") };
1701
+ },
1702
+ postToolHook: hooks.length === 0 ? undefined : async (name, input, result, isError) => {
1703
+ const r = await fireHooks("PostToolUse", hooks, {
1704
+ TOOL_NAME: name,
1705
+ TOOL_INPUT: JSON.stringify(input),
1706
+ TOOL_RESULT: result,
1707
+ TOOL_ERROR: isError ? "1" : "",
1708
+ SESSION_ID: session.id,
1709
+ LISA_HOME,
1710
+ CLAUDE_PROJECT_DIR: process.cwd(),
1711
+ }, process.cwd());
1712
+ if (r.rewriteResult != null)
1713
+ return { rewriteResult: r.rewriteResult };
1714
+ },
1715
+ onMessagePersist: (m) => session.appendMessage(m),
1716
+ hotReload: {
1717
+ initialFingerprint: fresh.fingerprint,
1718
+ rebuild: rebuildPrompt,
1719
+ },
1720
+ });
1721
+ history.length = 0;
1722
+ history.push(...result.history);
1723
+ send({ type: "done" });
1724
+ }
1725
+ catch (err) {
1726
+ send({ type: "error", message: err.message });
1727
+ }
1728
+ finally {
1729
+ moodBus.off("mood", onMood);
1730
+ res.end();
1731
+ }
1732
+ };
1733
+ // Queue behind any in-flight turn; the SSE stream stays open while we
1734
+ // wait, so the second tab just sees its reply start a little later.
1735
+ const job = chatChain.then(runTurn, runTurn);
1736
+ chatChain = job.then(() => { }, () => { });
1737
+ await job;
924
1738
  return;
925
1739
  }
926
1740
  if (req.method === "POST" && url === "/reflect") {
@@ -942,7 +1756,7 @@ self.addEventListener('fetch', (event) => {
942
1756
  res.writeHead(404);
943
1757
  res.end();
944
1758
  });
945
- server.listen(opts.port);
1759
+ server.listen(opts.port, host);
946
1760
  return server;
947
1761
  }
948
1762
  //# sourceMappingURL=server.js.map