@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
@@ -0,0 +1,1323 @@
1
+ /**
2
+ * Inline client <script> for the Lisa chat shell (MAIN_HTML).
3
+ *
4
+ * The full browser-side runtime: attachments, vision capture, voice
5
+ * dictation, SSE (mood/idle/claude), API-key gate, birth ritual, history
6
+ * infinite-scroll, modal panels, send + streaming, and the sidebar live
7
+ * wiring. Extracted verbatim from lisa-html.ts; re-embedded there as
8
+ * `<script>\n${MAIN_CLIENT_JS}\n</script>`.
9
+ *
10
+ * IMPORTANT: this is the literal text served to the browser, NOT executed
11
+ * as part of the build. Backslash escapes (e.g. `\\n`) are written here
12
+ * exactly as they must appear in the emitted <script>. html-syntax.test.ts
13
+ * compiles the result with vm.Script to catch a stray real newline inside
14
+ * a JS string literal, and lisa-html-snapshot.test.ts pins the exact bytes.
15
+ *
16
+ * No interpolation or backticks appear in the original script text, so it
17
+ * is a plain template literal with no ${} placeholders.
18
+ */
19
+ export const MAIN_CLIENT_JS = `const log = document.getElementById('log');
20
+ const input = document.getElementById('input');
21
+ const form = document.getElementById('form');
22
+ const sendBtn = document.getElementById('sendBtn');
23
+ const sessionEl = document.getElementById('sessionId');
24
+ const fileInput = document.getElementById('fileInput');
25
+ const attachPreview = document.getElementById('attachPreview');
26
+
27
+ // ── Attached files state ──────────────────────────────────────────
28
+ let pendingFiles = []; // Array of {name, mediaType, data (base64)}
29
+
30
+ function readFileAsBase64(file) {
31
+ return new Promise((resolve, reject) => {
32
+ const reader = new FileReader();
33
+ reader.onload = () => {
34
+ const dataUrl = reader.result;
35
+ const base64 = dataUrl.split(',')[1];
36
+ resolve(base64);
37
+ };
38
+ reader.onerror = reject;
39
+ reader.readAsDataURL(file);
40
+ });
41
+ }
42
+
43
+ function inferMediaType(file) {
44
+ if (file.type) return file.type;
45
+ const ext = file.name.split('.').pop().toLowerCase();
46
+ const map = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/plain', csv: 'text/csv', json: 'application/json' };
47
+ return map[ext] || 'application/octet-stream';
48
+ }
49
+
50
+ // ── Vision: screenshot → composer ──────────────────────────────────
51
+ // lisaAttachImage adds an already-encoded {name,mediaType,data} object to
52
+ // the pending attachments — used by both the in-page 📷 button and the
53
+ // native global hotkey (Lisa.app calls lisaCaptureAndAttach via JS bridge).
54
+ window.lisaAttachImage = function (file) {
55
+ if (!file || !file.data) return;
56
+ pendingFiles.push({
57
+ name: file.name || 'screenshot.png',
58
+ mediaType: file.mediaType || 'image/png',
59
+ data: file.data,
60
+ });
61
+ renderAttachPreview();
62
+ try { input.focus(); } catch (_) {}
63
+ };
64
+
65
+ // lisaPrefillComposer drops text into the composer WITHOUT sending — the user
66
+ // reads it and hits Enter to send (that send is the confirmation). Used by the
67
+ // island's screen-advisor "Optimize ▸" card (via the Swift bridge) and by the
68
+ // ?prefill= URL param (plain browser tabs).
69
+ window.lisaPrefillComposer = function (text) {
70
+ if (!text || !input) return;
71
+ input.value = String(text);
72
+ try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
73
+ try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}
74
+ };
75
+
76
+ // On load, honour ?prefill=… (the island opens /?prefill=<task> in a browser
77
+ // tab). Strip it from the URL afterwards so a refresh doesn't re-fill.
78
+ try {
79
+ var _pf = new URLSearchParams(location.search).get('prefill');
80
+ if (_pf) {
81
+ window.lisaPrefillComposer(_pf);
82
+ history.replaceState(null, '', location.pathname);
83
+ }
84
+ } catch (_) {}
85
+
86
+ // lisaCaptureAndAttach asks the server to run a screen capture, then
87
+ // attaches the result. mode: 'interactive' (crosshair, default) | 'full'.
88
+ // Returns true if an image was attached, false if cancelled/failed.
89
+ // Exposed on window so the native app's global hotkey can invoke it.
90
+ let capturing = false;
91
+ window.lisaCaptureAndAttach = async function (mode) {
92
+ if (capturing) return false;
93
+ capturing = true;
94
+ const btn = document.getElementById('captureBtn');
95
+ if (btn) btn.classList.add('flash');
96
+ try {
97
+ const res = await fetch('/api/vision/capture', {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ mode: mode || 'interactive' }),
101
+ });
102
+ if (!res.ok) {
103
+ console.warn('[vision] capture failed: HTTP ' + res.status);
104
+ return false;
105
+ }
106
+ const data = await res.json();
107
+ if (data.cancelled || !data.file) return false;
108
+ window.lisaAttachImage(data.file);
109
+ return true;
110
+ } catch (err) {
111
+ console.warn('[vision] capture error:', err);
112
+ return false;
113
+ } finally {
114
+ capturing = false;
115
+ if (btn) setTimeout(() => btn.classList.remove('flash'), 200);
116
+ }
117
+ };
118
+
119
+ function renderAttachPreview() {
120
+ attachPreview.innerHTML = '';
121
+ pendingFiles.forEach((f, i) => {
122
+ const chip = document.createElement('span');
123
+ chip.className = 'attach-chip';
124
+ chip.textContent = f.name;
125
+ const rm = document.createElement('button');
126
+ rm.type = 'button';
127
+ rm.className = 'attach-rm';
128
+ rm.textContent = '×';
129
+ rm.onclick = () => { pendingFiles.splice(i, 1); renderAttachPreview(); };
130
+ chip.appendChild(rm);
131
+ attachPreview.appendChild(chip);
132
+ });
133
+ }
134
+
135
+ fileInput.addEventListener('change', async () => {
136
+ for (const file of fileInput.files) {
137
+ const data = await readFileAsBase64(file);
138
+ pendingFiles.push({ name: file.name, mediaType: inferMediaType(file), data });
139
+ }
140
+ fileInput.value = '';
141
+ renderAttachPreview();
142
+ });
143
+
144
+ // The natural <label>→<input type=file> click-forwarding is supposed
145
+ // to open the picker without any JS. But in WKWebView under our
146
+ // layout it never reaches runOpenPanel. We add an explicit click
147
+ // listener that forwards the click synchronously (preserving the
148
+ // user-gesture context) and logs to console so we can verify in the
149
+ // inspector if it ever silently no-ops again.
150
+ // 📷 capture button → interactive crosshair screenshot into the composer.
151
+ const captureBtnEl = document.getElementById('captureBtn');
152
+ if (captureBtnEl) {
153
+ captureBtnEl.addEventListener('click', () => { void window.lisaCaptureAndAttach('interactive'); });
154
+ }
155
+
156
+ // ── Audio recording → transcribe → Lisa summarizes ─────────────────
157
+ // 🎙 toggles a MediaRecorder. On stop: POST the clip to /api/voice/transcribe
158
+ // (server-side Whisper), then send the transcript into the normal chat with a
159
+ // "summarize this" framing — so Lisa produces the summary in her own voice and
160
+ // it's persisted + discussable like any turn. First click prompts mic
161
+ // permission (browser-native).
162
+ const recordBtnEl = document.getElementById('recordBtn');
163
+ let mediaRecorder = null;
164
+ let recordedChunks = [];
165
+ let recordStream = null;
166
+ // 'dictation' (default): polish speech → composer for you to edit + send.
167
+ // 'summary' (long-press 🎙): the original record→Lisa-summarizes flow.
168
+ let recordMode = 'dictation';
169
+
170
+ function pickAudioMime() {
171
+ const prefs = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];
172
+ for (const m of prefs) {
173
+ try { if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; } catch (_) {}
174
+ }
175
+ return '';
176
+ }
177
+
178
+ async function startRecording() {
179
+ if (!navigator.mediaDevices || !window.MediaRecorder) {
180
+ el('div', 'err', '[voice] recording not supported in this browser');
181
+ return;
182
+ }
183
+ try {
184
+ recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });
185
+ } catch (err) {
186
+ el('div', 'err', '[voice] microphone access denied or unavailable');
187
+ return;
188
+ }
189
+ const mimeType = pickAudioMime();
190
+ recordedChunks = [];
191
+ mediaRecorder = mimeType ? new MediaRecorder(recordStream, { mimeType }) : new MediaRecorder(recordStream);
192
+ mediaRecorder.addEventListener('dataavailable', (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); });
193
+ mediaRecorder.addEventListener('stop', () => { void finishRecording(); });
194
+ mediaRecorder.start();
195
+ if (recordBtnEl) { recordBtnEl.classList.add('recording'); recordBtnEl.textContent = '⏹'; recordBtnEl.title = 'Stop recording'; }
196
+ }
197
+
198
+ function stopRecordingTracks() {
199
+ if (recordStream) { recordStream.getTracks().forEach((t) => t.stop()); recordStream = null; }
200
+ if (recordBtnEl) { recordBtnEl.classList.remove('recording'); recordBtnEl.textContent = '🎙'; recordBtnEl.title = 'Dictate — speak and Lisa drops polished text in the box (hold to record a summary)'; }
201
+ }
202
+
203
+ async function finishRecording() {
204
+ const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
205
+ stopRecordingTracks();
206
+ const mode = recordMode;
207
+ const blob = new Blob(recordedChunks, { type: mime });
208
+ recordedChunks = [];
209
+ if (blob.size === 0) return;
210
+ // Transient status line: dictation polishes; summary transcribes.
211
+ const pending = el('div', 'thinking', mode === 'dictation' ? '⋯ transcribing + polishing' : '⋯ transcribing recording');
212
+ try {
213
+ const data = await blobToBase64(blob);
214
+ const res = await fetch('/api/voice/transcribe', {
215
+ method: 'POST',
216
+ headers: { 'content-type': 'application/json' },
217
+ body: JSON.stringify({ data, mediaType: mime, mode }),
218
+ });
219
+ const out = await res.json();
220
+ if (pending) pending.remove();
221
+ if (!res.ok || out.error) {
222
+ el('div', 'err', '[voice] ' + (out.error || ('HTTP ' + res.status)));
223
+ return;
224
+ }
225
+ if (mode === 'dictation') {
226
+ // Typeless-style: drop the polished text into the composer (cursor at end)
227
+ // for you to review and send. Never auto-sends. Appends if you'd already
228
+ // started typing.
229
+ const text = (out.text || out.transcript || '').trim();
230
+ if (!text) { el('div', 'err', '[voice] (nothing dictated)'); return; }
231
+ const existing = input.value.trimEnd();
232
+ input.value = existing ? existing + '\\n' + text : text;
233
+ try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
234
+ try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}
235
+ return;
236
+ }
237
+ // summary mode: hand the transcript to Lisa with a summarize framing.
238
+ const transcript = (out.transcript || '').trim();
239
+ if (!transcript) { el('div', 'err', '[voice] (empty transcript)'); return; }
240
+ const framed =
241
+ "I just recorded some audio. Here's the transcript — please give me a clear, " +
242
+ "useful summary (key points, decisions, action items if any), then I might ask follow-ups.\\n\\n" +
243
+ "--- transcript ---\\n" + transcript;
244
+ send(framed);
245
+ } catch (err) {
246
+ if (pending) pending.remove();
247
+ el('div', 'err', '[voice] ' + (err && err.message ? err.message : 'transcription failed'));
248
+ }
249
+ }
250
+
251
+ function blobToBase64(blob) {
252
+ return new Promise((resolve, reject) => {
253
+ const reader = new FileReader();
254
+ reader.onload = () => resolve(String(reader.result).split(',')[1] || '');
255
+ reader.onerror = reject;
256
+ reader.readAsDataURL(blob);
257
+ });
258
+ }
259
+
260
+ if (recordBtnEl) {
261
+ // Short click → dictation (polished text into the composer). Press-and-hold
262
+ // (≥500ms) → summary (record → Lisa summarizes). A click always follows
263
+ // pointerup, so we just flag long-presses and read the flag on click.
264
+ let longPress = false;
265
+ let holdTimer = null;
266
+ const clearHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };
267
+ recordBtnEl.addEventListener('pointerdown', () => {
268
+ longPress = false;
269
+ clearHold();
270
+ holdTimer = setTimeout(() => { longPress = true; }, 500);
271
+ });
272
+ recordBtnEl.addEventListener('pointerup', clearHold);
273
+ recordBtnEl.addEventListener('pointercancel', clearHold);
274
+ recordBtnEl.addEventListener('pointerleave', clearHold);
275
+ recordBtnEl.addEventListener('click', () => {
276
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
277
+ mediaRecorder.stop();
278
+ longPress = false;
279
+ return;
280
+ }
281
+ recordMode = longPress ? 'summary' : 'dictation';
282
+ longPress = false;
283
+ if (recordBtnEl) recordBtnEl.title = recordMode === 'summary' ? 'Recording for summary — click to stop' : 'Dictating — click to stop';
284
+ void startRecording();
285
+ });
286
+ }
287
+
288
+ const attachBtnEl = document.getElementById('attachBtn');
289
+ if (attachBtnEl) {
290
+ attachBtnEl.addEventListener('click', (ev) => {
291
+ // Don't preventDefault — that cancels the implicit label-forward
292
+ // and removes the redundant gesture path. Letting both fire is
293
+ // fine because <input type=file>.click() fires the picker only
294
+ // once per user gesture.
295
+ console.log('[attach] click forwarded to fileInput');
296
+ try {
297
+ fileInput.click();
298
+ } catch (err) {
299
+ console.error('[attach] fileInput.click failed:', err);
300
+ }
301
+ });
302
+ }
303
+
304
+ // Paste-to-attach: when the user has copied an image (screenshot,
305
+ // image from a webpage, etc.) and presses ⌘V, pull the image off the
306
+ // clipboard and add it to pendingFiles — same path the file picker
307
+ // uses. Plain-text paste falls through to default behavior.
308
+ //
309
+ // We listen at the DOCUMENT level so paste works whether the textarea
310
+ // is focused, the chat log is focused, or focus is on a tool-block.
311
+ // (Some users press ⌘V right after launching the app, before clicking
312
+ // anywhere — element-scoped handlers miss that.)
313
+ //
314
+ // For text paste we MUST NOT preventDefault — let the browser route
315
+ // it to the focused element. Only intercept when we detect image
316
+ // items.
317
+ async function handlePastedClipboard(ev) {
318
+ const cb = ev.clipboardData;
319
+ if (!cb) return;
320
+
321
+ // Two clipboard surfaces — items (modern, exposes pasteboard files)
322
+ // and files (older, doesn't include MIME type for some types). We
323
+ // dedupe via the underlying File reference.
324
+ const fileMap = new Map(); // File → mediaType (string)
325
+ if (cb.items && cb.items.length) {
326
+ for (const item of cb.items) {
327
+ if (item.kind !== 'file') continue;
328
+ if (!item.type || !item.type.startsWith('image/')) continue;
329
+ const f = item.getAsFile();
330
+ if (f) fileMap.set(f, item.type);
331
+ }
332
+ }
333
+ if (cb.files && cb.files.length) {
334
+ for (const f of cb.files) {
335
+ if (f.type && f.type.startsWith('image/')) {
336
+ if (!fileMap.has(f)) fileMap.set(f, f.type || 'image/png');
337
+ }
338
+ }
339
+ }
340
+
341
+ if (fileMap.size === 0) return; // text paste — leave default behavior alone
342
+
343
+ ev.preventDefault();
344
+ // Keep focus on the textarea so the user can keep typing right after.
345
+ try { input.focus(); } catch (_) {}
346
+
347
+ for (const [file, mediaType] of fileMap) {
348
+ try {
349
+ const data = await readFileAsBase64(file);
350
+ const ext = (mediaType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');
351
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
352
+ const name = file.name && file.name !== 'image.png'
353
+ ? file.name
354
+ : 'pasted-' + ts + '.' + ext;
355
+ pendingFiles.push({ name, mediaType, data });
356
+ } catch (err) {
357
+ console.warn('[paste] failed to read image:', err);
358
+ }
359
+ }
360
+ renderAttachPreview();
361
+ }
362
+
363
+ input.addEventListener('paste', (ev) => { void handlePastedClipboard(ev); });
364
+ // Document-level fallback. If focus is somewhere other than the
365
+ // textarea (e.g. just-launched, no focus yet), the textarea-level
366
+ // paste listener never fires; this one catches it.
367
+ document.addEventListener('paste', (ev) => {
368
+ // Avoid double-firing when the textarea ALREADY handled it: if
369
+ // ev.defaultPrevented is set, the textarea listener already
370
+ // consumed the paste and added the file.
371
+ if (ev.defaultPrevented) return;
372
+ void handlePastedClipboard(ev);
373
+ });
374
+
375
+ // Surface session id from server header on first request. The titlebar
376
+ // shows the same id with a leading "·" separator after the "Lisa" label.
377
+ fetch('/session').then(r => r.json()).then(s => {
378
+ sessionEl.textContent = s.id;
379
+ const titlebarSession = document.getElementById('titlebarSession');
380
+ if (titlebarSession) titlebarSession.textContent = '· ' + s.id;
381
+ });
382
+
383
+ // ── Persistent /events SSE: mood updates + idle messages + Claude
384
+ // activity, lifetime of page.
385
+ function connectEvents() {
386
+ const es = new EventSource('/events');
387
+ let idlePulseEl = null;
388
+ es.addEventListener('message', (e) => {
389
+ const ev = JSON.parse(e.data);
390
+ if (ev.type === 'mood') {
391
+ setMood(ev.slug);
392
+ } else if (ev.type === 'idle_start') {
393
+ if (!idlePulseEl) {
394
+ idlePulseEl = document.createElement('div');
395
+ idlePulseEl.className = 'idle-pulse';
396
+ idlePulseEl.textContent = '⋯ Lisa is thinking on her own time ⋯';
397
+ log.appendChild(idlePulseEl);
398
+ log.scrollTop = log.scrollHeight;
399
+ }
400
+ } else if (ev.type === 'idle_message') {
401
+ if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
402
+ const block = document.createElement('div');
403
+ block.className = 'idle-block';
404
+ const head = document.createElement('div');
405
+ head.className = 'idle-head';
406
+ head.textContent = '★ WHILE YOU WERE AWAY';
407
+ const time = document.createElement('span');
408
+ time.className = 'idle-time';
409
+ try { time.textContent = new Date(ev.at).toLocaleTimeString(); } catch {}
410
+ head.appendChild(time);
411
+ block.appendChild(head);
412
+ const bodyEl = document.createElement('div');
413
+ bodyEl.textContent = ev.text;
414
+ block.appendChild(bodyEl);
415
+ log.appendChild(block);
416
+ log.scrollTop = log.scrollHeight;
417
+ // sidebar reflection card mirrors the latest while-you-were-away
418
+ if (typeof updateReflection === 'function') updateReflection(ev.text);
419
+ } else if (ev.type === 'idle_done') {
420
+ if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
421
+ } else if (ev.type === 'idle_error') {
422
+ if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
423
+ const e2 = document.createElement('div');
424
+ e2.className = 'err';
425
+ e2.textContent = '[idle error] ' + ev.message;
426
+ log.appendChild(e2);
427
+ } else if (ev.type === 'agent_session_update') {
428
+ // D4a — sidebar multi-agent monitor refresh (defined later in the
429
+ // "sidebar live wiring" block). Generalized from claude_session_update
430
+ // so codex / opencode / git / … sessions update the sidebar too.
431
+ if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();
432
+ }
433
+ });
434
+ es.onerror = () => {
435
+ es.close();
436
+ setTimeout(connectEvents, 3000); // reconnect
437
+ };
438
+ }
439
+ connectEvents();
440
+
441
+ // ── API key config gate: show overlay if no key is configured ─────
442
+ const cfgOverlay = document.getElementById('cfgOverlay');
443
+ const cfgForm = document.getElementById('cfgForm');
444
+ const cfgAnthropic = document.getElementById('cfgAnthropic');
445
+ const cfgOpenai = document.getElementById('cfgOpenai');
446
+ const cfgSaveBtn = document.getElementById('cfgSave');
447
+ const cfgError = document.getElementById('cfgError');
448
+
449
+ cfgForm.addEventListener('submit', async (ev) => {
450
+ ev.preventDefault();
451
+ cfgError.textContent = '';
452
+ const anthropic = cfgAnthropic.value.trim();
453
+ const openai = cfgOpenai.value.trim();
454
+ if (!anthropic) {
455
+ cfgError.textContent = 'ANTHROPIC_API_KEY is required.';
456
+ return;
457
+ }
458
+ cfgSaveBtn.disabled = true;
459
+ try {
460
+ const res = await fetch('/api/config/save', {
461
+ method: 'POST',
462
+ headers: {'content-type': 'application/json'},
463
+ body: JSON.stringify({ anthropicKey: anthropic, openaiKey: openai || undefined }),
464
+ });
465
+ if (!res.ok) {
466
+ const txt = await res.text().catch(() => '');
467
+ cfgError.textContent = 'Save failed: HTTP ' + res.status + (txt ? ' — ' + txt.slice(0, 120) : '');
468
+ cfgSaveBtn.disabled = false;
469
+ return;
470
+ }
471
+ cfgAnthropic.value = '';
472
+ cfgOpenai.value = '';
473
+ cfgOverlay.classList.remove('open');
474
+ maybeBirth();
475
+ } catch (err) {
476
+ cfgError.textContent = 'Save failed: ' + err.message;
477
+ cfgSaveBtn.disabled = false;
478
+ }
479
+ });
480
+
481
+ // ── Birth ritual: show overlay if Lisa hasn't been born yet ───────
482
+ const birthOverlay = document.getElementById('birthOverlay');
483
+ const birthStepsEl = document.getElementById('birthSteps');
484
+ const birthFinalEl = document.getElementById('birthFinal');
485
+ const birthEnterBtn = document.getElementById('birthEnter');
486
+ const birthErrorEl = document.getElementById('birthError');
487
+
488
+ birthEnterBtn.addEventListener('click', () => {
489
+ birthOverlay.classList.remove('open');
490
+ setTimeout(() => location.reload(), 300);
491
+ });
492
+
493
+ async function maybeBirth() {
494
+ const status = await fetch('/api/soul').then(r => r.json());
495
+ if (status.born) return;
496
+ birthOverlay.classList.add('open');
497
+ startBirthStream();
498
+ }
499
+
500
+ function appendBirthStep(step) {
501
+ const prevActive = birthStepsEl.querySelector('.birth-step.active');
502
+ if (prevActive) {
503
+ prevActive.classList.remove('active');
504
+ prevActive.classList.add('done');
505
+ }
506
+ const div = document.createElement('div');
507
+ div.className = 'birth-step active';
508
+ const name = document.createElement('div');
509
+ name.className = 'step-name';
510
+ name.textContent = step;
511
+ div.appendChild(name);
512
+ const detail = document.createElement('div');
513
+ detail.className = 'step-detail';
514
+ const cursor = document.createElement('span');
515
+ cursor.className = 'step-cursor';
516
+ detail.appendChild(cursor);
517
+ div.appendChild(detail);
518
+ birthStepsEl.appendChild(div);
519
+ setTimeout(() => div.classList.add('shown'), 50);
520
+ return detail;
521
+ }
522
+
523
+ function typewriter(el, text, done) {
524
+ const cursor = el.querySelector('.step-cursor');
525
+ if (cursor) cursor.remove();
526
+ let i = 0;
527
+ const speed = Math.max(8, Math.min(28, 600 / text.length));
528
+ function tick() {
529
+ if (i >= text.length) {
530
+ const c = document.createElement('span');
531
+ c.className = 'step-cursor';
532
+ el.appendChild(c);
533
+ done && done();
534
+ return;
535
+ }
536
+ el.appendChild(document.createTextNode(text[i]));
537
+ i++;
538
+ el.parentElement.parentElement.scrollTop = el.parentElement.parentElement.scrollHeight;
539
+ setTimeout(tick, speed);
540
+ }
541
+ tick();
542
+ }
543
+
544
+ async function startBirthStream() {
545
+ birthErrorEl.textContent = '';
546
+ let currentDetail = null;
547
+ let queue = [];
548
+ let processing = false;
549
+
550
+ function processQueue() {
551
+ if (processing) return;
552
+ if (queue.length === 0) return;
553
+ processing = true;
554
+ const ev = queue.shift();
555
+ if (ev.kind === 'step') {
556
+ currentDetail = appendBirthStep(ev.name);
557
+ typewriter(currentDetail, ev.detail || '', () => {
558
+ processing = false;
559
+ processQueue();
560
+ });
561
+ } else if (ev.kind === 'done') {
562
+ const last = birthStepsEl.querySelector('.birth-step.active');
563
+ if (last) { last.classList.remove('active'); last.classList.add('done'); }
564
+ birthFinalEl.textContent = ev.message;
565
+ birthFinalEl.classList.add('shown');
566
+ birthEnterBtn.classList.add('shown');
567
+ processing = false;
568
+ } else if (ev.kind === 'error') {
569
+ birthErrorEl.textContent = ev.message;
570
+ processing = false;
571
+ }
572
+ }
573
+
574
+ try {
575
+ const res = await fetch('/api/birth', { method: 'POST' });
576
+ if (!res.ok) {
577
+ birthErrorEl.textContent = 'Birth failed: HTTP ' + res.status + '. Check ANTHROPIC_API_KEY.';
578
+ return;
579
+ }
580
+ const reader = res.body.getReader();
581
+ const decoder = new TextDecoder();
582
+ let buf = '';
583
+ while (true) {
584
+ const { value, done } = await reader.read();
585
+ if (done) break;
586
+ buf += decoder.decode(value, { stream: true });
587
+ let idx;
588
+ while ((idx = buf.indexOf('\\n\\n')) >= 0) {
589
+ const event = buf.slice(0, idx);
590
+ buf = buf.slice(idx + 2);
591
+ const m = event.match(/^data: (.*)$/m);
592
+ if (!m) continue;
593
+ const ev = JSON.parse(m[1]);
594
+ queue.push(ev);
595
+ processQueue();
596
+ }
597
+ }
598
+ } catch (err) {
599
+ birthErrorEl.textContent = 'Birth failed: ' + err.message;
600
+ }
601
+ }
602
+
603
+ async function startupGate() {
604
+ let cfg;
605
+ try {
606
+ cfg = await fetch('/api/config/status').then(r => r.json());
607
+ } catch {
608
+ return;
609
+ }
610
+ if (!cfg.configured) {
611
+ cfgOverlay.classList.add('open');
612
+ setTimeout(() => cfgAnthropic.focus(), 50);
613
+ return;
614
+ }
615
+ maybeBirth();
616
+ }
617
+ startupGate();
618
+
619
+ // ── history load & infinite-scroll ──────────────────────────────────
620
+ let historyPage = 0;
621
+ let historyLoading = false;
622
+ let historyExhausted = false;
623
+
624
+ function textOfMessage(msg) {
625
+ if (typeof msg.content === 'string') return msg.content.trim();
626
+ if (!Array.isArray(msg.content)) return '';
627
+ return msg.content
628
+ .filter(b => b.type === 'text')
629
+ .map(b => b.text)
630
+ .join('')
631
+ .trim();
632
+ }
633
+
634
+ function prependHistoryMessages(messages) {
635
+ const fragment = document.createDocumentFragment();
636
+ for (const msg of messages) {
637
+ const text = textOfMessage(msg);
638
+ if (!text) continue;
639
+ const roleDiv = document.createElement('div');
640
+ roleDiv.className = 'role ' + (msg.role === 'user' ? 'you' : 'lisa');
641
+ roleDiv.textContent = msg.role === 'user' ? 'YOU' : 'LISA';
642
+ const span = document.createElement('span');
643
+ span.className = 'msg';
644
+ span.textContent = text;
645
+ fragment.appendChild(roleDiv);
646
+ fragment.appendChild(span);
647
+ }
648
+ log.insertBefore(fragment, log.firstChild);
649
+ }
650
+
651
+ async function loadHistoryPage() {
652
+ if (historyLoading || historyExhausted) return;
653
+ historyLoading = true;
654
+ const prevScrollHeight = log.scrollHeight;
655
+ try {
656
+ const res = await fetch('/api/history?page=' + historyPage);
657
+ const data = await res.json();
658
+ if (data.messages && data.messages.length) {
659
+ prependHistoryMessages(data.messages);
660
+ log.scrollTop = log.scrollHeight - prevScrollHeight;
661
+ historyPage++;
662
+ }
663
+ if (!data.hasMore) {
664
+ historyExhausted = true;
665
+ if (historyPage > 1) {
666
+ const marker = document.createElement('div');
667
+ marker.style.cssText = 'text-align:center;color:var(--fg-3);font-size:11px;padding:8px 0;letter-spacing:0.06em;';
668
+ marker.textContent = '— end of history —';
669
+ log.insertBefore(marker, log.firstChild);
670
+ }
671
+ }
672
+ } finally {
673
+ historyLoading = false;
674
+ }
675
+ }
676
+
677
+ loadHistoryPage();
678
+ log.addEventListener('scroll', () => {
679
+ if (log.scrollTop < 80) loadHistoryPage();
680
+ });
681
+
682
+ // ── mascot crossfade on mood event ──────────────────────────────────
683
+ const mascotEl = document.getElementById('mascot');
684
+ const mascotTagEl = document.getElementById('mascotTag');
685
+ let currentMood = 'neutral';
686
+ function setMood(slug) {
687
+ if (!slug || slug === currentMood) return;
688
+ const url = '/assets/lisa/' + encodeURIComponent(slug) + '.png';
689
+ const probe = new Image();
690
+ probe.onload = () => {
691
+ mascotEl.classList.add('fading');
692
+ setTimeout(() => {
693
+ mascotEl.src = url;
694
+ mascotTagEl.textContent = 'mood: ' + slug;
695
+ mascotEl.classList.remove('fading');
696
+ currentMood = slug;
697
+ }, 250);
698
+ };
699
+ probe.onerror = () => { /* asset not generated yet — keep current */ };
700
+ probe.src = url;
701
+ }
702
+
703
+ // ── modal panel: SOUL / SKILLS / MEMORY / TOOLS ──────────────────────
704
+ const modalBg = document.getElementById('modalBg');
705
+ const modalTitle = document.getElementById('modalTitle');
706
+ const modalBody = document.getElementById('modalBody');
707
+ const modalClose = document.getElementById('modalClose');
708
+
709
+ function escapeHtml(s) {
710
+ return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
711
+ }
712
+ function openModal(title, html) {
713
+ modalTitle.textContent = title;
714
+ modalBody.innerHTML = html;
715
+ modalBg.classList.add('open');
716
+ }
717
+ function closeModal() { modalBg.classList.remove('open'); }
718
+ modalClose.addEventListener('click', closeModal);
719
+ modalBg.addEventListener('click', (e) => { if (e.target === modalBg) closeModal(); });
720
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
721
+
722
+ async function showSkills() {
723
+ openModal('SKILLS', '<div class="empty">loading…</div>');
724
+ const data = await fetch('/api/skills').then(r => r.json());
725
+ if (!data.skills.length) {
726
+ modalBody.innerHTML = '<div class="empty">No skills saved yet. Lisa will start saving useful workflows as you use her.</div>';
727
+ return;
728
+ }
729
+ modalBody.innerHTML = data.skills.map(s =>
730
+ '<div class="item"><div class="name">' + escapeHtml(s.name) + '</div><div class="desc">' + escapeHtml(s.description) + '</div></div>'
731
+ ).join('');
732
+ }
733
+
734
+ async function showMemory() {
735
+ openModal('MEMORY', '<div class="empty">loading…</div>');
736
+ const data = await fetch('/api/memory').then(r => r.json());
737
+ modalBody.innerHTML =
738
+ '<h3>USER.md — what Lisa remembers about you</h3>' +
739
+ '<pre>' + escapeHtml(data.user || '(empty)') + '</pre>' +
740
+ '<h3>MEMORY.md — Lisa\\'s working notes</h3>' +
741
+ '<pre>' + escapeHtml(data.memory || '(empty)') + '</pre>';
742
+ }
743
+
744
+ async function showTools() {
745
+ openModal('TOOLS', '<div class="empty">loading…</div>');
746
+ const data = await fetch('/api/tools').then(r => r.json());
747
+ modalBody.innerHTML = data.tools.map(t =>
748
+ '<div class="item"><div class="name">' + escapeHtml(t.name) + '</div><div class="desc">' + escapeHtml(t.description) + '</div></div>'
749
+ ).join('');
750
+ }
751
+
752
+ async function showPlans() {
753
+ openModal('CODING PLANS', '<div class="empty">loading…</div>');
754
+ let data;
755
+ try { data = await fetch('/api/plans').then(r => r.json()); }
756
+ catch (e) { modalBody.innerHTML = '<div class="empty">failed to load plans</div>'; return; }
757
+ const intro = '<div class="empty">Run coding work on a subscription you already pay for (Claude Pro/Max · ChatGPT/Codex · Copilot) instead of a metered API key. LISA drives the vendor CLI you are logged into, so it bills your plan, not a key. This does not change her own model.</div>';
758
+ const rows = (data.plans || []).map(function (p) {
759
+ const star = p.selected ? ' — selected' : '';
760
+ const usage = p.usage ? '<div class="desc">usage: ' + escapeHtml(p.usage) + '</div>' : '';
761
+ let btn;
762
+ if (p.selected) btn = '<button class="plan-select" data-plan="' + escapeHtml(p.id) + '" disabled>selected</button>';
763
+ else if (p.available) btn = '<button class="plan-select" data-plan="' + escapeHtml(p.id) + '">use this</button>';
764
+ else btn = '<button class="plan-select" data-plan="' + escapeHtml(p.id) + '" disabled>not installed</button>';
765
+ return '<div class="item"><div class="name">' + escapeHtml(p.mark + ' ' + p.label + star) + '</div><div class="desc">' + escapeHtml(p.detail) + '</div>' + usage + btn + '</div>';
766
+ }).join('');
767
+ const clear = '<div class="item"><button class="plan-select" data-plan="none">clear selection</button></div>';
768
+ modalBody.innerHTML = intro + rows + clear;
769
+ document.querySelectorAll('.plan-select').forEach(function (btn) {
770
+ if (btn.disabled) return;
771
+ btn.addEventListener('click', function () { selectPlan(btn.dataset.plan); });
772
+ });
773
+ }
774
+
775
+ async function selectPlan(plan) {
776
+ try {
777
+ const res = await fetch('/api/plans/select', {
778
+ method: 'POST',
779
+ headers: { 'content-type': 'application/json' },
780
+ body: JSON.stringify({ plan: plan }),
781
+ });
782
+ if (!res.ok) {
783
+ const t = await res.text().catch(function () { return ''; });
784
+ modalBody.insertAdjacentHTML('afterbegin', '<div class="empty" style="color:var(--err-color)">select failed: ' + escapeHtml(String(res.status) + (t ? ' — ' + t : '')) + '</div>');
785
+ return;
786
+ }
787
+ showPlans();
788
+ } catch (e) {
789
+ modalBody.insertAdjacentHTML('afterbegin', '<div class="empty" style="color:var(--err-color)">select error: ' + escapeHtml(e.message) + '</div>');
790
+ }
791
+ }
792
+
793
+ async function showSoul() {
794
+ openModal('★ SOUL', '<div class="empty">loading…</div>');
795
+ const data = await fetch('/api/soul').then(r => r.json());
796
+ if (!data.born) {
797
+ modalBody.innerHTML = '<div class="empty">Lisa hasn\\'t been born yet. Restart the CLI without --no-birth and the birth ritual will run.</div>';
798
+ return;
799
+ }
800
+ const s = data.summary;
801
+ let html = '';
802
+ html += '<h3>name</h3><div>' + escapeHtml(s.name) + '</div>';
803
+ html += '<h3>born</h3><div>' + escapeHtml(s.seed.bornAt) + ' · big5(O' + Math.round(s.seed.bigFive.openness*100) + ' C' + Math.round(s.seed.bigFive.conscientiousness*100) + ' E' + Math.round(s.seed.bigFive.extraversion*100) + ' A' + Math.round(s.seed.bigFive.agreeableness*100) + ' N' + Math.round(s.seed.bigFive.neuroticism*100) + ')</div>';
804
+ html += '<h3>identity</h3><pre>' + escapeHtml(s.identity) + '</pre>';
805
+ html += '<h3>purpose</h3><pre>' + escapeHtml(s.purpose) + '</pre>';
806
+ html += '<h3>constitution</h3><pre>' + escapeHtml(s.constitution) + '</pre>';
807
+ if (s.values?.length) {
808
+ html += '<h3>values</h3>' + s.values.map(v =>
809
+ '<div class="item"><div class="name">' + escapeHtml(v.title) + '</div><div class="desc">' + escapeHtml(v.body) + '</div></div>'
810
+ ).join('');
811
+ }
812
+ if (s.opinions?.length) {
813
+ html += '<h3>opinions</h3>' + s.opinions.map(o =>
814
+ '<div class="item"><div class="name">' + escapeHtml(o.stance) + ' (conf ' + o.confidence.toFixed(2) + ')</div></div>'
815
+ ).join('');
816
+ }
817
+ if (s.desires?.length) {
818
+ html += '<h3>desires</h3>' + s.desires.map(d =>
819
+ '<div class="item"><div class="name">' + escapeHtml(d.what) + (d.actionable ? ' [heartbeat-active]' : '') + '</div><div class="desc">' + escapeHtml(d.why) + '</div></div>'
820
+ ).join('');
821
+ }
822
+ html += '<h3>emotions</h3>' + Object.entries(s.emotions.values).map(([k, v]) => {
823
+ const len = 12;
824
+ const filled = Math.round(Math.abs(v) * len);
825
+ const bar = '█'.repeat(filled) + '░'.repeat(len - filled);
826
+ return '<div class="item"><div class="name">' + escapeHtml(k) + '</div><div class="desc">' + (v < 0 ? '-' : ' ') + bar + ' ' + v.toFixed(2) + '</div></div>';
827
+ }).join('');
828
+ if (s.tampered?.length) {
829
+ html += '<h3>⚠ tampered</h3><div>External edits detected on: ' + s.tampered.map(escapeHtml).join(', ') + '</div>';
830
+ }
831
+ html += '<h3 style="color: var(--fg-3); font-size: 10px;">privacy note</h3><div class="empty">Her journal lives at ~/.lisa/soul/journal/ but is intentionally not shown here — that is hers to keep.</div>';
832
+ modalBody.innerHTML = html;
833
+ }
834
+
835
+ document.querySelectorAll('.badge').forEach(b => {
836
+ b.addEventListener('click', () => {
837
+ const which = b.dataset.panel;
838
+ if (which === 'soul') showSoul();
839
+ else if (which === 'skills') showSkills();
840
+ else if (which === 'memory') showMemory();
841
+ else if (which === 'tools') showTools();
842
+ else if (which === 'plans') showPlans();
843
+ });
844
+ });
845
+
846
+ let currentLisaSpan = null;
847
+ let pendingTools = new Map();
848
+ let thinkingEl = null;
849
+
850
+ function el(tag, cls, text) {
851
+ const node = document.createElement(tag);
852
+ if (cls) node.className = cls;
853
+ if (text != null) node.textContent = text;
854
+ log.appendChild(node);
855
+ log.scrollTop = log.scrollHeight;
856
+ return node;
857
+ }
858
+
859
+ function ensureLisaSpan() {
860
+ if (currentLisaSpan) return currentLisaSpan;
861
+ if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
862
+ el('div', 'role lisa', 'LISA');
863
+ currentLisaSpan = el('span', 'msg', '');
864
+ return currentLisaSpan;
865
+ }
866
+
867
+ function previewInput(name, input) {
868
+ if (!input || typeof input !== 'object') return '';
869
+ const order = ['command', 'pattern', 'query', 'path', 'description', 'audio_path', 'text', 'name', 'action', 'entry'];
870
+ for (const k of order) {
871
+ if (typeof input[k] === 'string' && input[k]) {
872
+ let v = input[k].replace(/\\s+/g, ' ').trim();
873
+ if (v.length > 120) v = v.slice(0, 117) + '...';
874
+ return v;
875
+ }
876
+ }
877
+ try {
878
+ const s = JSON.stringify(input);
879
+ return s.length > 120 ? s.slice(0, 117) + '...' : s;
880
+ } catch { return ''; }
881
+ }
882
+
883
+ async function send(message) {
884
+ input.value = '';
885
+ input.style.height = 'auto';
886
+ sendBtn.disabled = true;
887
+ el('div', 'role you', 'YOU');
888
+ el('span', 'msg', message || '(attachment)');
889
+ if (pendingFiles.length) {
890
+ const names = pendingFiles.map(f => f.name).join(', ');
891
+ el('span', 'msg attach-label', '📎 ' + names);
892
+ }
893
+ const filesToSend = [...pendingFiles];
894
+ pendingFiles = [];
895
+ renderAttachPreview();
896
+ currentLisaSpan = null;
897
+ pendingTools.clear();
898
+ thinkingEl = el('div', 'thinking', '⋯ thinking');
899
+ try {
900
+ const res = await fetch('/chat', {
901
+ method: 'POST',
902
+ headers: {'content-type': 'application/json'},
903
+ body: JSON.stringify({message, files: filesToSend}),
904
+ });
905
+ const reader = res.body.getReader();
906
+ const decoder = new TextDecoder();
907
+ let buf = '';
908
+ while (true) {
909
+ const {value, done} = await reader.read();
910
+ if (done) break;
911
+ buf += decoder.decode(value, {stream: true});
912
+ let idx;
913
+ while ((idx = buf.indexOf('\\n\\n')) >= 0) {
914
+ const evRaw = buf.slice(0, idx);
915
+ buf = buf.slice(idx + 2);
916
+ const m = evRaw.match(/^data: (.*)$/m);
917
+ if (!m) continue;
918
+ const ev = JSON.parse(m[1]);
919
+ if (ev.type === 'text') {
920
+ ensureLisaSpan().textContent += ev.text;
921
+ } else if (ev.type === 'tool_start') {
922
+ if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
923
+ currentLisaSpan = null;
924
+ const block = el('div', 'tool-block', null);
925
+ const head = document.createElement('div');
926
+ head.className = 'tool-head';
927
+ head.innerHTML = '<span class="tool-icon">⚙</span> <span class="tool-name"></span> <span class="tool-spinner">...</span>';
928
+ head.querySelector('.tool-name').textContent = ev.name;
929
+ block.appendChild(head);
930
+ const preview = previewInput(ev.name, ev.input);
931
+ if (preview) {
932
+ const p = document.createElement('div');
933
+ p.className = 'tool-input';
934
+ p.textContent = preview;
935
+ block.appendChild(p);
936
+ }
937
+ pendingTools.set(ev.name, block);
938
+ } else if (ev.type === 'tool_end') {
939
+ const block = pendingTools.get(ev.name);
940
+ if (block) {
941
+ const spinner = block.querySelector('.tool-spinner');
942
+ if (spinner) spinner.textContent = ev.isError ? '✗' : '✓';
943
+ if (ev.isError) block.classList.add('tool-error');
944
+ if (ev.resultPreview) {
945
+ const r = document.createElement('div');
946
+ r.className = 'tool-result';
947
+ r.textContent = ev.resultPreview;
948
+ block.appendChild(r);
949
+ }
950
+ pendingTools.delete(ev.name);
951
+ }
952
+ } else if (ev.type === 'mood') {
953
+ setMood(ev.slug);
954
+ } else if (ev.type === 'error') {
955
+ el('div', 'err', '[error] ' + ev.message);
956
+ } else if (ev.type === 'done') {
957
+ if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
958
+ }
959
+ }
960
+ }
961
+ if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
962
+ } catch (err) {
963
+ if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
964
+ el('div', 'err', '[error] ' + err.message);
965
+ } finally {
966
+ sendBtn.disabled = false;
967
+ input.focus();
968
+ }
969
+ }
970
+
971
+ form.addEventListener('submit', (ev) => {
972
+ ev.preventDefault();
973
+ const msg = input.value.trim();
974
+ if (msg || pendingFiles.length) send(msg);
975
+ });
976
+
977
+ input.addEventListener('keydown', (ev) => {
978
+ if (ev.key === 'Enter' && !ev.shiftKey) {
979
+ ev.preventDefault();
980
+ form.dispatchEvent(new Event('submit'));
981
+ }
982
+ });
983
+ input.addEventListener('input', () => {
984
+ input.style.height = 'auto';
985
+ input.style.height = Math.min(input.scrollHeight, 200) + 'px';
986
+ });
987
+
988
+ // ── PWA: register service worker + iOS install hint ─────────────────
989
+ if ('serviceWorker' in navigator) {
990
+ navigator.serviceWorker.register('/sw.js').catch(err => {
991
+ console.warn('[pwa] sw register failed:', err);
992
+ });
993
+ }
994
+ (function() {
995
+ const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
996
+ const isStandalone = window.matchMedia('(display-mode: standalone)').matches
997
+ || (window.navigator).standalone === true;
998
+ if (!isiOS || isStandalone) return;
999
+ if (localStorage.getItem('lisa.pwa.dismissed') === '1') return;
1000
+ setTimeout(() => {
1001
+ const banner = document.createElement('div');
1002
+ banner.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;background:var(--bg-card-strong);border:1px solid var(--border-strong);border-radius:12px;padding:10px 12px;color:var(--fg);font-size:12px;z-index:9999;display:flex;gap:8px;align-items:center;';
1003
+ banner.innerHTML = '✦ Add Lisa to Home Screen: Share button → "Add to Home Screen"';
1004
+ const dismiss = document.createElement('button');
1005
+ dismiss.textContent = '✕';
1006
+ dismiss.style.cssText = 'background:transparent;border:none;color:var(--fg-2);cursor:pointer;font-size:14px;margin-left:auto;';
1007
+ dismiss.onclick = () => {
1008
+ localStorage.setItem('lisa.pwa.dismissed', '1');
1009
+ banner.remove();
1010
+ };
1011
+ banner.appendChild(dismiss);
1012
+ document.body.appendChild(banner);
1013
+ }, 5000);
1014
+ })();
1015
+
1016
+ // ════════════════════════════════════════════════════════════════════
1017
+ // ── Sidebar live wiring ─────────────────────────────────────────────
1018
+ //
1019
+ // Populates the new sidebar blocks introduced by the redesign:
1020
+ // - identity card sub-line (born YYYY-MM-DD · NN days)
1021
+ // - "currently wanting" paragraph (top actionable desire)
1022
+ // - Claude Code monitor card (active sessions)
1023
+ // - "last reflection" mini-card (most recent ★ idle message)
1024
+ // Wires to /api/island/ping + /api/claude/sessions + /api/soul, and
1025
+ // piggy-backs on the connectEvents() SSE listener above for live
1026
+ // claude_session_update + idle_message refreshes.
1027
+ // ════════════════════════════════════════════════════════════════════
1028
+ (function setupSidebarLive() {
1029
+ const sbDesire = document.getElementById('sbDesire');
1030
+ const sbClaudeCount = document.getElementById('sbClaudeCount');
1031
+ const sbClaudeRows = document.getElementById('sbClaudeRows');
1032
+ const sbReflection = document.getElementById('sbReflection');
1033
+ const sbReflectionBody = document.getElementById('sbReflectionBody');
1034
+ const sbSessionBadge = document.getElementById('sbSessionBadge');
1035
+ const identitySub = document.getElementById('identitySub');
1036
+
1037
+ // Active session window matches the watcher's ACTIVE_WINDOW_MS.
1038
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
1039
+
1040
+ function relativeTime(iso) {
1041
+ const ms = Date.now() - new Date(iso).getTime();
1042
+ if (ms < 30_000) return 'just now';
1043
+ if (ms < 60_000) return Math.round(ms / 1000) + 's';
1044
+ if (ms < 3600_000) return Math.round(ms / 60_000) + 'm';
1045
+ return Math.round(ms / 3600_000) + 'h';
1046
+ }
1047
+
1048
+ function setDesire(text) {
1049
+ sbDesire.textContent = text || '(nothing actively pursued)';
1050
+ }
1051
+
1052
+ window.updateReflection = function (text) {
1053
+ if (!text) { sbReflection.style.display = 'none'; return; }
1054
+ sbReflection.style.display = '';
1055
+ sbReflectionBody.textContent = '"' + text.replace(/^["“”]+|["“”]+$/g, '').trim() + '"';
1056
+ };
1057
+
1058
+ // Compact one-line activity summary — mirrors agent-roster.ts formatActivity
1059
+ // (kept inline because this client script is a no-interpolation template
1060
+ // literal; the island uses the source-injected shared version).
1061
+ function sbActivity(s) {
1062
+ const a = s.activity;
1063
+ if (!a || typeof a !== 'object') return '';
1064
+ if (a.pendingPermission) return '⚠ wants to run ' + a.pendingPermission;
1065
+ const bits = [];
1066
+ if (a.lastError) bits.push('✗ ' + a.lastError);
1067
+ const prog = [];
1068
+ if (typeof a.turnCount === 'number' && a.turnCount > 0) prog.push('turn ' + a.turnCount);
1069
+ if (a.tokens && (a.tokens.input || a.tokens.output)) {
1070
+ const tot = (a.tokens.input || 0) + (a.tokens.output || 0);
1071
+ prog.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok');
1072
+ }
1073
+ if (prog.length) bits.push(prog.join(' '));
1074
+ if (a.lastCommandName) bits.push('$ ' + a.lastCommandName);
1075
+ const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';
1076
+ const file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : '';
1077
+ if (tool && file) bits.push(tool + ' ' + file);
1078
+ else if (tool) bits.push(tool);
1079
+ else if (file) bits.push(file);
1080
+ return bits.join(' · ');
1081
+ }
1082
+
1083
+ // POST a control action to the right agent family (managed|pty), then refresh.
1084
+ function agentAction(fam, id, action, body) {
1085
+ fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, {
1086
+ method: 'POST',
1087
+ headers: body ? { 'content-type': 'application/json' } : {},
1088
+ body: body ? JSON.stringify(body) : undefined,
1089
+ }).then(function () {
1090
+ if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();
1091
+ }).catch(function () {});
1092
+ }
1093
+
1094
+ // Show a PTY agent's captured terminal tail in the modal — explicit + on
1095
+ // demand (it's content, so it's never folded into the structural roster).
1096
+ function ptyOutput(id) {
1097
+ fetch('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (r) {
1098
+ return r.ok ? r.json() : null;
1099
+ }).then(function (d) {
1100
+ if (!d) return;
1101
+ openModal('agent output', '<pre>' + escapeHtml(d.output || '(no output yet)') + '</pre>');
1102
+ }).catch(function () {});
1103
+ }
1104
+
1105
+ function setClaudeSessions(sessions) {
1106
+ const cutoff = Date.now() - ACTIVE_WINDOW_MS;
1107
+ const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff);
1108
+ sbClaudeCount.textContent = String(recent.length);
1109
+ // sort: errors first, then waiting, then working, then by mtime
1110
+ const rank = { error: 0, waiting: 1, working: 2, unknown: 3 };
1111
+ const rows = recent.slice().sort((a, b) => {
1112
+ const ra = rank[a.state] ?? 9;
1113
+ const rb = rank[b.state] ?? 9;
1114
+ if (ra !== rb) return ra - rb;
1115
+ return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();
1116
+ }).slice(0, 8);
1117
+ while (sbClaudeRows.firstChild) sbClaudeRows.removeChild(sbClaudeRows.firstChild);
1118
+ if (rows.length === 0) {
1119
+ const empty = document.createElement('div');
1120
+ empty.className = 'session-empty';
1121
+ empty.textContent = '(idle)';
1122
+ sbClaudeRows.appendChild(empty);
1123
+ return;
1124
+ }
1125
+ for (const s of rows) {
1126
+ const row = document.createElement('div');
1127
+ row.className = 'session-row';
1128
+ const pip = document.createElement('div');
1129
+ pip.className = 'pip ' + (s.state || 'unknown');
1130
+ const name = document.createElement('div');
1131
+ name.className = 'name';
1132
+ // D4a — agent-kind chip inline before the project; omitted for plain
1133
+ // Claude so existing rows read unchanged.
1134
+ if (s.agent && s.agent !== 'claude-code') {
1135
+ const badge = document.createElement('span');
1136
+ badge.className = 'agent-badge';
1137
+ badge.textContent = s.agent;
1138
+ badge.title = s.agent;
1139
+ name.appendChild(badge);
1140
+ }
1141
+ // Label by git branch when available (more meaningful than a worktree
1142
+ // hash), stripping the claude/ prefix; fall back to the project name.
1143
+ // (String ops, not a regex — a /\// here would be mangled by the outer
1144
+ // template literal that wraps this client script.)
1145
+ let label = s.project;
1146
+ if (s.activity && s.activity.gitBranch) {
1147
+ const br = String(s.activity.gitBranch);
1148
+ label = br.indexOf('claude/') === 0 ? br.slice(7) : br;
1149
+ }
1150
+ name.appendChild(document.createTextNode(label));
1151
+ const when = document.createElement('div');
1152
+ when.className = 'when';
1153
+ when.textContent = relativeTime(s.lastMtime);
1154
+ row.appendChild(pip);
1155
+ row.appendChild(name);
1156
+ row.appendChild(when);
1157
+ // Second line: structural activity (turns/tokens/tool·file, ⚠pending, ✗err).
1158
+ const actText = sbActivity(s);
1159
+ if (actText) {
1160
+ const act = document.createElement('div');
1161
+ act.className = 'session-act';
1162
+ act.textContent = actText;
1163
+ act.title = actText;
1164
+ row.appendChild(act);
1165
+ }
1166
+ // Controllable agents get inline controls: managed → approve/deny a pending
1167
+ // tool, send a follow-up, cancel; pty (real CLI under a PTY) → send, view
1168
+ // terminal output, cancel. Externally-started CLIs have no control channel
1169
+ // (no s.controllable) → observe only.
1170
+ const fam = s.controllable;
1171
+ if (fam) {
1172
+ const id = s.sessionId;
1173
+ const ctrl = document.createElement('div');
1174
+ ctrl.className = 'session-ctrl';
1175
+ const pending = fam === 'managed' && s.activity && s.activity.pendingPermission;
1176
+ if (pending) {
1177
+ const ap = document.createElement('button');
1178
+ ap.className = 'mc approve'; ap.textContent = '✓ approve';
1179
+ ap.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: true }); });
1180
+ const dn = document.createElement('button');
1181
+ dn.className = 'mc deny'; dn.textContent = '✕ deny';
1182
+ dn.addEventListener('click', function (e) { e.stopPropagation(); agentAction('managed', id, 'approve', { allow: false }); });
1183
+ ctrl.appendChild(ap); ctrl.appendChild(dn);
1184
+ } else if (s.state !== 'done') {
1185
+ const inp = document.createElement('input');
1186
+ inp.className = 'mc-send'; inp.type = 'text'; inp.placeholder = fam === 'pty' ? 'type into the CLI…' : 'send a follow-up…';
1187
+ inp.addEventListener('click', function (e) { e.stopPropagation(); });
1188
+ inp.addEventListener('keydown', function (e) {
1189
+ if (e.key === 'Enter' && inp.value.trim()) { e.preventDefault(); agentAction(fam, id, 'send', { text: inp.value.trim() }); inp.value = ''; }
1190
+ });
1191
+ ctrl.appendChild(inp);
1192
+ }
1193
+ if (fam === 'pty') {
1194
+ const out = document.createElement('button');
1195
+ out.className = 'mc'; out.textContent = '▤'; out.title = 'View terminal output';
1196
+ out.addEventListener('click', function (e) { e.stopPropagation(); ptyOutput(id); });
1197
+ ctrl.appendChild(out);
1198
+ }
1199
+ if (s.state !== 'done') {
1200
+ const cancel = document.createElement('button');
1201
+ cancel.className = 'mc cancel'; cancel.textContent = '⏹'; cancel.title = 'Cancel agent';
1202
+ cancel.addEventListener('click', function (e) { e.stopPropagation(); agentAction(fam, id, 'cancel', null); });
1203
+ ctrl.appendChild(cancel);
1204
+ }
1205
+ if (ctrl.childNodes.length) row.appendChild(ctrl);
1206
+ }
1207
+ // Idle external claude session → adopt it: LISA resumes it under a PTY and
1208
+ // drives the continuation (then it shows as a controllable pty row). Only
1209
+ // offered for idle sessions; a live one can't be resumed without corrupting
1210
+ // its transcript (the server 409s, surfaced in the modal).
1211
+ if (s.resumable) {
1212
+ const ctrl = document.createElement('div');
1213
+ ctrl.className = 'session-ctrl';
1214
+ const adopt = document.createElement('button');
1215
+ adopt.className = 'mc adopt'; adopt.textContent = '⇲ adopt';
1216
+ adopt.title = 'Resume this session under LISA — then send / answer / cancel / view it';
1217
+ adopt.addEventListener('click', function (e) {
1218
+ e.stopPropagation();
1219
+ fetch('/api/agents/pty/start', {
1220
+ method: 'POST', headers: { 'content-type': 'application/json' },
1221
+ body: JSON.stringify({ agent: 'claude', resumeSessionId: s.sessionId, cwd: s.cwd || '' }),
1222
+ }).then(function (r) {
1223
+ if (!r.ok) { return r.text().then(function (t) { openModal('adopt', '<pre>' + escapeHtml(t) + '</pre>'); }); }
1224
+ if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();
1225
+ }).catch(function () {});
1226
+ });
1227
+ ctrl.appendChild(adopt);
1228
+ row.appendChild(ctrl);
1229
+ }
1230
+ row.title = (s.stateReason ? s.state + ' · ' + s.stateReason : s.state) + ' · ' + s.project + ' · ' + s.sessionId;
1231
+ sbClaudeRows.appendChild(row);
1232
+ }
1233
+ }
1234
+
1235
+ async function refreshPing() {
1236
+ try {
1237
+ const r = await fetch('/api/island/ping');
1238
+ if (!r.ok) return;
1239
+ const data = await r.json();
1240
+ setDesire(data.current_desire);
1241
+ if (data.last_idle_message_text) {
1242
+ window.updateReflection(data.last_idle_message_text);
1243
+ }
1244
+ } catch {}
1245
+ }
1246
+
1247
+ // Exposed so the SSE handler above can call this on
1248
+ // agent_session_update events without redeclaring the helper. D4a — the
1249
+ // multi-agent snapshot (all agents), not just Claude Code.
1250
+ window.refreshClaudeSessions = async function () {
1251
+ try {
1252
+ const r = await fetch('/api/agents/sessions');
1253
+ if (!r.ok) return;
1254
+ const data = await r.json();
1255
+ setClaudeSessions(data.sessions || []);
1256
+ } catch {}
1257
+ };
1258
+
1259
+ // "Delegate a task" → start an agent. managed = LISA-run (controllable);
1260
+ // claude/codex = a real CLI under a PTY (Stage C spike, needs LISA_PTY_AGENTS=1
1261
+ // — a 503 surfaces its hint in the modal).
1262
+ const sbDelegate = document.getElementById('sbDelegate');
1263
+ if (sbDelegate) {
1264
+ sbDelegate.addEventListener('submit', function (e) {
1265
+ e.preventDefault();
1266
+ const inp = document.getElementById('sbDelegateTask');
1267
+ const kindEl = document.getElementById('sbDelegateKind');
1268
+ const task = inp && inp.value.trim();
1269
+ if (!task) return;
1270
+ const kind = kindEl ? kindEl.value : 'managed';
1271
+ const url = kind === 'managed' ? '/api/agents/managed/start' : '/api/agents/pty/start';
1272
+ const body = kind === 'managed' ? { task: task } : { agent: kind, task: task };
1273
+ fetch(url, {
1274
+ method: 'POST',
1275
+ headers: { 'content-type': 'application/json' },
1276
+ body: JSON.stringify(body),
1277
+ }).then(function (r) {
1278
+ if (!r.ok) { return r.text().then(function (t) { openModal('agent', '<pre>' + escapeHtml(t) + '</pre>'); }); }
1279
+ if (inp) inp.value = '';
1280
+ if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();
1281
+ }).catch(function () {});
1282
+ });
1283
+ }
1284
+
1285
+ async function refreshIdentity() {
1286
+ try {
1287
+ const r = await fetch('/api/soul');
1288
+ if (!r.ok) return;
1289
+ const data = await r.json();
1290
+ if (!data.born) return;
1291
+ const bornAt = data.summary?.seed?.bornAt;
1292
+ if (!bornAt) return;
1293
+ const born = new Date(bornAt);
1294
+ if (Number.isNaN(born.getTime())) return;
1295
+ const days = Math.max(0, Math.floor((Date.now() - born.getTime()) / 86400000));
1296
+ const ymd = born.toISOString().slice(0, 10);
1297
+ identitySub.textContent = 'born ' + ymd + ' · ' + days + ' day' + (days === 1 ? '' : 's');
1298
+ } catch {}
1299
+ }
1300
+
1301
+ async function refreshSessionsBadge() {
1302
+ try {
1303
+ const r = await fetch('/api/sessions');
1304
+ if (!r.ok) return;
1305
+ const data = await r.json();
1306
+ const n = Array.isArray(data.sessions) ? data.sessions.length : 0;
1307
+ sbSessionBadge.textContent = String(n);
1308
+ } catch {
1309
+ // /api/sessions is optional — leave the badge as-is on failure
1310
+ }
1311
+ }
1312
+
1313
+ // Bootstrap + periodic resync. SSE handles the fast-path updates;
1314
+ // these timers are belt-and-braces in case the stream silently dies.
1315
+ refreshPing();
1316
+ window.refreshClaudeSessions();
1317
+ refreshIdentity();
1318
+ refreshSessionsBadge();
1319
+ setInterval(refreshPing, 30_000);
1320
+ setInterval(window.refreshClaudeSessions, 60_000);
1321
+ setInterval(refreshSessionsBadge, 5 * 60_000);
1322
+ })();`;
1323
+ //# sourceMappingURL=lisa-client.js.map