@oratis/lisa 0.8.0 → 0.11.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 (320) 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/mcp/config.d.ts +4 -0
  150. package/dist/mcp/config.d.ts.map +1 -1
  151. package/dist/mcp/config.js +34 -0
  152. package/dist/mcp/config.js.map +1 -1
  153. package/dist/memory/embedding.d.ts +45 -0
  154. package/dist/memory/embedding.d.ts.map +1 -0
  155. package/dist/memory/embedding.js +139 -0
  156. package/dist/memory/embedding.js.map +1 -0
  157. package/dist/memory/search_tool.d.ts.map +1 -1
  158. package/dist/memory/search_tool.js +6 -2
  159. package/dist/memory/search_tool.js.map +1 -1
  160. package/dist/memory/vector.d.ts +14 -3
  161. package/dist/memory/vector.d.ts.map +1 -1
  162. package/dist/memory/vector.js +50 -0
  163. package/dist/memory/vector.js.map +1 -1
  164. package/dist/model/local.d.ts +43 -0
  165. package/dist/model/local.d.ts.map +1 -0
  166. package/dist/model/local.js +118 -0
  167. package/dist/model/local.js.map +1 -0
  168. package/dist/model/plan-usage.d.ts +41 -0
  169. package/dist/model/plan-usage.d.ts.map +1 -0
  170. package/dist/model/plan-usage.js +168 -0
  171. package/dist/model/plan-usage.js.map +1 -0
  172. package/dist/model/plans.d.ts +73 -0
  173. package/dist/model/plans.d.ts.map +1 -0
  174. package/dist/model/plans.js +246 -0
  175. package/dist/model/plans.js.map +1 -0
  176. package/dist/orchestrator/recent-recap.d.ts +2 -0
  177. package/dist/orchestrator/recent-recap.d.ts.map +1 -0
  178. package/dist/orchestrator/recent-recap.js +20 -0
  179. package/dist/orchestrator/recent-recap.js.map +1 -0
  180. package/dist/providers/anthropic.d.ts +1 -0
  181. package/dist/providers/anthropic.d.ts.map +1 -1
  182. package/dist/providers/anthropic.js +10 -2
  183. package/dist/providers/anthropic.js.map +1 -1
  184. package/dist/providers/fallback.d.ts +23 -0
  185. package/dist/providers/fallback.d.ts.map +1 -0
  186. package/dist/providers/fallback.js +29 -0
  187. package/dist/providers/fallback.js.map +1 -0
  188. package/dist/providers/gemini.d.ts +2 -0
  189. package/dist/providers/gemini.d.ts.map +1 -1
  190. package/dist/providers/gemini.js +24 -10
  191. package/dist/providers/gemini.js.map +1 -1
  192. package/dist/providers/openai.d.ts.map +1 -1
  193. package/dist/providers/openai.js +4 -1
  194. package/dist/providers/openai.js.map +1 -1
  195. package/dist/providers/registry.d.ts +18 -2
  196. package/dist/providers/registry.d.ts.map +1 -1
  197. package/dist/providers/registry.js +56 -9
  198. package/dist/providers/registry.js.map +1 -1
  199. package/dist/reflect.d.ts +55 -0
  200. package/dist/reflect.d.ts.map +1 -1
  201. package/dist/reflect.js +148 -54
  202. package/dist/reflect.js.map +1 -1
  203. package/dist/sense/log.d.ts +9 -0
  204. package/dist/sense/log.d.ts.map +1 -0
  205. package/dist/sense/log.js +63 -0
  206. package/dist/sense/log.js.map +1 -0
  207. package/dist/sense/screen.d.ts +43 -0
  208. package/dist/sense/screen.d.ts.map +1 -0
  209. package/dist/sense/screen.js +107 -0
  210. package/dist/sense/screen.js.map +1 -0
  211. package/dist/sense/service.d.ts +16 -0
  212. package/dist/sense/service.d.ts.map +1 -0
  213. package/dist/sense/service.js +33 -0
  214. package/dist/sense/service.js.map +1 -0
  215. package/dist/sense/types.d.ts +35 -0
  216. package/dist/sense/types.d.ts.map +1 -0
  217. package/dist/sense/types.js +11 -0
  218. package/dist/sense/types.js.map +1 -0
  219. package/dist/sense/voice.d.ts +28 -0
  220. package/dist/sense/voice.d.ts.map +1 -0
  221. package/dist/sense/voice.js +64 -0
  222. package/dist/sense/voice.js.map +1 -0
  223. package/dist/soul/git.d.ts.map +1 -1
  224. package/dist/soul/git.js +35 -16
  225. package/dist/soul/git.js.map +1 -1
  226. package/dist/soul/store.d.ts +22 -0
  227. package/dist/soul/store.d.ts.map +1 -1
  228. package/dist/soul/store.js +69 -14
  229. package/dist/soul/store.js.map +1 -1
  230. package/dist/soul/summary.d.ts +10 -0
  231. package/dist/soul/summary.d.ts.map +1 -0
  232. package/dist/soul/summary.js +57 -0
  233. package/dist/soul/summary.js.map +1 -0
  234. package/dist/soul/tools.d.ts.map +1 -1
  235. package/dist/soul/tools.js +13 -24
  236. package/dist/soul/tools.js.map +1 -1
  237. package/dist/soul/types.d.ts +9 -0
  238. package/dist/soul/types.d.ts.map +1 -1
  239. package/dist/soul/types.js.map +1 -1
  240. package/dist/subagent.d.ts +7 -0
  241. package/dist/subagent.d.ts.map +1 -1
  242. package/dist/subagent.js +3 -1
  243. package/dist/subagent.js.map +1 -1
  244. package/dist/tools/dispatch_agent.d.ts +13 -1
  245. package/dist/tools/dispatch_agent.d.ts.map +1 -1
  246. package/dist/tools/dispatch_agent.js +56 -12
  247. package/dist/tools/dispatch_agent.js.map +1 -1
  248. package/dist/tools/dispatch_status.d.ts +22 -0
  249. package/dist/tools/dispatch_status.d.ts.map +1 -0
  250. package/dist/tools/dispatch_status.js +57 -0
  251. package/dist/tools/dispatch_status.js.map +1 -0
  252. package/dist/tools/github.d.ts +34 -0
  253. package/dist/tools/github.d.ts.map +1 -0
  254. package/dist/tools/github.js +140 -0
  255. package/dist/tools/github.js.map +1 -0
  256. package/dist/tools/github_link.d.ts +36 -0
  257. package/dist/tools/github_link.d.ts.map +1 -0
  258. package/dist/tools/github_link.js +127 -0
  259. package/dist/tools/github_link.js.map +1 -0
  260. package/dist/tools/mcp.d.ts +28 -0
  261. package/dist/tools/mcp.d.ts.map +1 -0
  262. package/dist/tools/mcp.js +55 -0
  263. package/dist/tools/mcp.js.map +1 -0
  264. package/dist/tools/npm_info.d.ts +21 -0
  265. package/dist/tools/npm_info.d.ts.map +1 -0
  266. package/dist/tools/npm_info.js +102 -0
  267. package/dist/tools/npm_info.js.map +1 -0
  268. package/dist/tools/registry.d.ts +29 -0
  269. package/dist/tools/registry.d.ts.map +1 -1
  270. package/dist/tools/registry.js +75 -0
  271. package/dist/tools/registry.js.map +1 -1
  272. package/dist/tools/run_on_plan.d.ts +46 -0
  273. package/dist/tools/run_on_plan.d.ts.map +1 -0
  274. package/dist/tools/run_on_plan.js +89 -0
  275. package/dist/tools/run_on_plan.js.map +1 -0
  276. package/dist/tools/takoapi.d.ts +60 -0
  277. package/dist/tools/takoapi.d.ts.map +1 -0
  278. package/dist/tools/takoapi.js +181 -0
  279. package/dist/tools/takoapi.js.map +1 -0
  280. package/dist/tools/validate.d.ts +22 -0
  281. package/dist/tools/validate.d.ts.map +1 -0
  282. package/dist/tools/validate.js +50 -0
  283. package/dist/tools/validate.js.map +1 -0
  284. package/dist/voice/dictation.d.ts +51 -0
  285. package/dist/voice/dictation.d.ts.map +1 -0
  286. package/dist/voice/dictation.js +70 -0
  287. package/dist/voice/dictation.js.map +1 -0
  288. package/dist/web/agent-roster.d.ts +56 -0
  289. package/dist/web/agent-roster.d.ts.map +1 -0
  290. package/dist/web/agent-roster.js +109 -0
  291. package/dist/web/agent-roster.js.map +1 -0
  292. package/dist/web/devices.d.ts +27 -0
  293. package/dist/web/devices.d.ts.map +1 -0
  294. package/dist/web/devices.js +113 -0
  295. package/dist/web/devices.js.map +1 -0
  296. package/dist/web/island.d.ts +5 -1
  297. package/dist/web/island.d.ts.map +1 -1
  298. package/dist/web/island.js +427 -67
  299. package/dist/web/island.js.map +1 -1
  300. package/dist/web/lisa-client.d.ts +20 -0
  301. package/dist/web/lisa-client.d.ts.map +1 -0
  302. package/dist/web/lisa-client.js +1323 -0
  303. package/dist/web/lisa-client.js.map +1 -0
  304. package/dist/web/lisa-css.d.ts +13 -0
  305. package/dist/web/lisa-css.d.ts.map +1 -0
  306. package/dist/web/lisa-css.js +1163 -0
  307. package/dist/web/lisa-css.js.map +1 -0
  308. package/dist/web/lisa-html.d.ts +1 -1
  309. package/dist/web/lisa-html.d.ts.map +1 -1
  310. package/dist/web/lisa-html.js +16 -2091
  311. package/dist/web/lisa-html.js.map +1 -1
  312. package/dist/web/push.d.ts +76 -0
  313. package/dist/web/push.d.ts.map +1 -0
  314. package/dist/web/push.js +163 -0
  315. package/dist/web/push.js.map +1 -0
  316. package/dist/web/server.d.ts +19 -0
  317. package/dist/web/server.d.ts.map +1 -1
  318. package/dist/web/server.js +898 -65
  319. package/dist/web/server.js.map +1 -1
  320. 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,16 +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";
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";
26
44
  import { loadScreenAdvisorConfig, saveScreenAdvisorConfig, normalizeConfig, analyzeScreenshot, } from "../screen_advisor/engine.js";
27
45
  import os from "node:os";
28
46
  import { LISA_HOME } from "../paths.js";
29
47
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
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
+ }
31
93
  async function resumeOrCreateWebSession(model) {
32
94
  const lastId = await readActiveWebSession();
33
95
  if (lastId) {
@@ -61,6 +123,14 @@ async function resumeOrCreateWebSession(model) {
61
123
  return s;
62
124
  }
63
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
+ }
64
134
  const snapshot = await buildSystemPromptSnapshot();
65
135
  const initialFingerprint = await getPromptFingerprint();
66
136
  // Per-process hot-reload cache for the web server: same shape as cli's
@@ -99,6 +169,13 @@ export async function startWebServer(opts) {
99
169
  const { messages: savedMessages } = await session.readMessagePage(0, 9999);
100
170
  const history = [...savedMessages];
101
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();
102
179
  // ── Persistent /events SSE subscribers (mood + idle broadcasts) ─────
103
180
  const eventClients = new Set();
104
181
  const broadcast = (event) => {
@@ -126,11 +203,15 @@ export async function startWebServer(opts) {
126
203
  const hub = new OrchestratorHub(orchestratorCfg, {
127
204
  log: (msg) => console.error(msg),
128
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) });
129
209
  hub.on("update", (session) => {
130
210
  // L6 — record the transition in the orchestrator journal so the
131
211
  // cross-agent recap can answer "what happened while I was away?" even for
132
212
  // sessions that have since ended. (Dedups consecutive same-state events.)
133
213
  recordEvent(session);
214
+ pushBridge.onAgentUpdate(session);
134
215
  // New generalized event for the multi-agent UI.
135
216
  broadcast({ type: "agent_session_update", ...session });
136
217
  // Back-compat: the island still listens for claude_session_update as a
@@ -158,6 +239,7 @@ export async function startWebServer(opts) {
158
239
  // card — pull-friendly, not an interrupt. lastIdleMessage is assigned
159
240
  // inside the async callback (runs after all locals init; no TDZ issue).
160
241
  const ADVISE_INTERVAL_MS = 5 * 60_000;
242
+ let lastAdvisorSuggestions = null;
161
243
  const adviseTimer = setInterval(() => {
162
244
  void (async () => {
163
245
  try {
@@ -171,6 +253,19 @@ export async function startWebServer(opts) {
171
253
  const at = new Date().toISOString();
172
254
  lastIdleMessage = { text, at };
173
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 });
174
269
  console.error(`[advisor] surfaced ${surface.length} suggestion(s)`);
175
270
  }
176
271
  catch (err) {
@@ -192,7 +287,11 @@ export async function startWebServer(opts) {
192
287
  let screenTimer = null;
193
288
  let screenTickRunning = false;
194
289
  const screenTick = async () => {
195
- 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"))
196
295
  return;
197
296
  screenTickRunning = true;
198
297
  try {
@@ -236,7 +335,25 @@ export async function startWebServer(opts) {
236
335
  restartScreenTimer();
237
336
  if (screenCfg.enabled) {
238
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
+ }
239
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
+ });
240
357
  // ── Island unread tracking (Phase 1 of MAC_ISLAND_PLAN) ─────────────
241
358
  // The island widget caches "last idle_message" so a fresh tab opening
242
359
  // mid-conversation knows there's something to read. Cleared via
@@ -279,6 +396,7 @@ export async function startWebServer(opts) {
279
396
  });
280
397
  lastIdleMessage = { text: result.text, at: startedAt };
281
398
  broadcast({ type: "idle_message", text: result.text, at: startedAt });
399
+ pushBridge.onIdleMessage(result.text);
282
400
  }
283
401
  }
284
402
  catch (err) {
@@ -295,14 +413,65 @@ export async function startWebServer(opts) {
295
413
  }
296
414
  const server = http.createServer(async (req, res) => {
297
415
  const url = req.url ?? "/";
298
- 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("/?"))) {
299
468
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
300
469
  res.end(MAIN_HTML);
301
470
  return;
302
471
  }
303
472
  // Island widget — designed to be opened in a tiny browser window
304
473
  // (Arc, Vivaldi PWA, Safari split). See docs/MAC_ISLAND_PLAN.md.
305
- if (req.method === "GET" && url === "/island") {
474
+ if (req.method === "GET" && (url === "/island" || url.startsWith("/island?"))) {
306
475
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
307
476
  res.end(ISLAND_HTML);
308
477
  return;
@@ -453,8 +622,30 @@ export async function startWebServer(opts) {
453
622
  await fs.writeFile(tmp, Buffer.from(payload.data, "base64"));
454
623
  console.error(`[voice] transcribing ${Buffer.from(payload.data, "base64").length} bytes (${ext})`);
455
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);
629
+ // Dictation mode (Typeless-equivalent): polish the raw transcript into
630
+ // the clean text the speaker intended (filler/repetition removed,
631
+ // self-corrections applied, punctuation + formatting), to drop into the
632
+ // composer. Falls back to the raw transcript if the polish call fails.
633
+ let text;
634
+ if (payload.mode === "dictation") {
635
+ try {
636
+ text = await polishDictation({
637
+ provider: getProvider(),
638
+ model: opts.model,
639
+ transcript,
640
+ });
641
+ }
642
+ catch (err) {
643
+ console.error(`[voice] dictation polish failed: ${err.message}`);
644
+ text = transcript;
645
+ }
646
+ }
456
647
  res.writeHead(200, { "content-type": "application/json" });
457
- res.end(JSON.stringify({ transcript }));
648
+ res.end(JSON.stringify(text !== undefined ? { transcript, text } : { transcript }));
458
649
  }
459
650
  catch (err) {
460
651
  res.writeHead(500, { "content-type": "application/json" });
@@ -471,13 +662,549 @@ export async function startWebServer(opts) {
471
662
  res.end(JSON.stringify({ ok: true }));
472
663
  return;
473
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
+ }
474
1195
  // Cross-agent session snapshot (O1). Returns every active session
475
1196
  // across all integrations, normalized. activity (Tier 2) is present
476
1197
  // when the integration runs at visibility ≥ "activity".
477
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();
478
1202
  const sessions = hub.list().map((s) => ({
479
1203
  ...s,
480
1204
  lastMtime: new Date(s.lastMtime).toISOString(),
1205
+ ...(s.agent === "claude-code" && !s.controllable && !live.has(s.sessionId)
1206
+ ? { resumable: true }
1207
+ : {}),
481
1208
  }));
482
1209
  res.writeHead(200, { "content-type": "application/json" });
483
1210
  res.end(JSON.stringify({ sessions }));
@@ -677,6 +1404,68 @@ self.addEventListener('fetch', (event) => {
677
1404
  }));
678
1405
  return;
679
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
+ }
680
1469
  if (req.method === "GET" && url === "/api/config/status") {
681
1470
  res.writeHead(200, { "content-type": "application/json" });
682
1471
  res.end(JSON.stringify({
@@ -828,7 +1617,23 @@ self.addEventListener('fetch', (event) => {
828
1617
  let body = "";
829
1618
  for await (const chunk of req)
830
1619
  body += chunk.toString("utf8");
831
- 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
+ }
832
1637
  // User just talked — reset the idle watcher.
833
1638
  try {
834
1639
  getIdleWatcher(60 * 60_000).tick();
@@ -844,64 +1649,92 @@ self.addEventListener('fetch', (event) => {
844
1649
  moodBus.on("mood", onMood);
845
1650
  // Send the current mood immediately so a fresh tab knows where to start.
846
1651
  send({ type: "mood", slug: moodBus.current() });
847
- try {
848
- // Use the freshest cached prompt for this chat. If soul / skills /
849
- // memory changed since the previous chat, rebuildPrompt() picks it up.
850
- const fresh = await rebuildPrompt();
851
- const result = await runAgent({
852
- provider: getProvider(),
853
- systemPrompt: fresh.text,
854
- tools: opts.tools,
855
- toolCtx: {
856
- cwd: process.cwd(),
857
- signal: abort.signal,
858
- log: () => { },
859
- },
860
- history,
861
- userMessage: message,
862
- userFiles: files,
863
- model: opts.model,
864
- thinking: opts.thinking,
865
- onEvent: (ev) => {
866
- if (ev.type === "text_delta" && ev.text)
867
- send({ type: "text", text: ev.text });
868
- if (ev.type === "tool_call_start")
869
- send({
870
- type: "tool_start",
871
- name: ev.toolName,
872
- input: ev.toolInput,
873
- });
874
- if (ev.type === "tool_call_end")
875
- send({
876
- type: "tool_end",
877
- name: ev.toolName,
878
- isError: ev.isError === true,
879
- resultPreview: typeof ev.toolResult === "string"
880
- ? ev.toolResult.slice(0, 200)
881
- : "",
882
- });
883
- if (ev.type === "system_prompt_rebuilt")
884
- send({ type: "soul_reload", message: ev.message ?? "" });
885
- if (ev.type === "error")
886
- send({ type: "error", message: ev.message ?? "" });
887
- },
888
- onMessagePersist: (m) => session.appendMessage(m),
889
- hotReload: {
890
- initialFingerprint: fresh.fingerprint,
891
- rebuild: rebuildPrompt,
892
- },
893
- });
894
- history.length = 0;
895
- history.push(...result.history);
896
- send({ type: "done" });
897
- }
898
- catch (err) {
899
- send({ type: "error", message: err.message });
900
- }
901
- finally {
902
- moodBus.off("mood", onMood);
903
- res.end();
904
- }
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;
905
1738
  return;
906
1739
  }
907
1740
  if (req.method === "POST" && url === "/reflect") {
@@ -923,7 +1756,7 @@ self.addEventListener('fetch', (event) => {
923
1756
  res.writeHead(404);
924
1757
  res.end();
925
1758
  });
926
- server.listen(opts.port);
1759
+ server.listen(opts.port, host);
927
1760
  return server;
928
1761
  }
929
1762
  //# sourceMappingURL=server.js.map