@oratis/lisa 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +102 -9
  2. package/README.zh-CN.md +101 -9
  3. package/completions/_lisa +1 -0
  4. package/completions/lisa.bash +1 -1
  5. package/completions/lisa.fish +1 -0
  6. package/dist/agent.d.ts.map +1 -1
  7. package/dist/agent.js +12 -0
  8. package/dist/agent.js.map +1 -1
  9. package/dist/channels/imessage.d.ts +16 -0
  10. package/dist/channels/imessage.d.ts.map +1 -1
  11. package/dist/channels/imessage.js +29 -8
  12. package/dist/channels/imessage.js.map +1 -1
  13. package/dist/cli.js +23 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/env.js +6 -1
  16. package/dist/env.js.map +1 -1
  17. package/dist/heartbeat/config.d.ts +10 -0
  18. package/dist/heartbeat/config.d.ts.map +1 -1
  19. package/dist/heartbeat/config.js +15 -3
  20. package/dist/heartbeat/config.js.map +1 -1
  21. package/dist/heartbeat/install.d.ts.map +1 -1
  22. package/dist/heartbeat/install.js +80 -15
  23. package/dist/heartbeat/install.js.map +1 -1
  24. package/dist/heartbeat/runner.d.ts.map +1 -1
  25. package/dist/heartbeat/runner.js +30 -0
  26. package/dist/heartbeat/runner.js.map +1 -1
  27. package/dist/integrations/claude-code/parser.d.ts +56 -0
  28. package/dist/integrations/claude-code/parser.d.ts.map +1 -0
  29. package/dist/integrations/claude-code/parser.js +224 -0
  30. package/dist/integrations/claude-code/parser.js.map +1 -0
  31. package/dist/integrations/claude-code/watcher.d.ts +127 -0
  32. package/dist/integrations/claude-code/watcher.d.ts.map +1 -0
  33. package/dist/integrations/claude-code/watcher.js +361 -0
  34. package/dist/integrations/claude-code/watcher.js.map +1 -0
  35. package/dist/llm.d.ts +0 -6
  36. package/dist/llm.d.ts.map +1 -1
  37. package/dist/llm.js +0 -10
  38. package/dist/llm.js.map +1 -1
  39. package/dist/memory/vector.d.ts +5 -1
  40. package/dist/memory/vector.d.ts.map +1 -1
  41. package/dist/memory/vector.js +58 -1
  42. package/dist/memory/vector.js.map +1 -1
  43. package/dist/mood-bus.d.ts +11 -2
  44. package/dist/mood-bus.d.ts.map +1 -1
  45. package/dist/mood-bus.js +15 -2
  46. package/dist/mood-bus.js.map +1 -1
  47. package/dist/sessions/store.d.ts +1 -5
  48. package/dist/sessions/store.d.ts.map +1 -1
  49. package/dist/sessions/store.js +0 -11
  50. package/dist/sessions/store.js.map +1 -1
  51. package/dist/soul/lock.d.ts +38 -0
  52. package/dist/soul/lock.d.ts.map +1 -0
  53. package/dist/soul/lock.js +103 -0
  54. package/dist/soul/lock.js.map +1 -0
  55. package/dist/soul/paths.d.ts.map +1 -1
  56. package/dist/soul/paths.js +12 -6
  57. package/dist/soul/paths.js.map +1 -1
  58. package/dist/soul/slug.d.ts +40 -0
  59. package/dist/soul/slug.d.ts.map +1 -0
  60. package/dist/soul/slug.js +78 -0
  61. package/dist/soul/slug.js.map +1 -0
  62. package/dist/soul/store.d.ts +12 -1
  63. package/dist/soul/store.d.ts.map +1 -1
  64. package/dist/soul/store.js +40 -23
  65. package/dist/soul/store.js.map +1 -1
  66. package/dist/soul/tools.d.ts.map +1 -1
  67. package/dist/soul/tools.js +10 -3
  68. package/dist/soul/tools.js.map +1 -1
  69. package/dist/tools/task.d.ts +0 -1
  70. package/dist/tools/task.d.ts.map +1 -1
  71. package/dist/tools/task.js +0 -3
  72. package/dist/tools/task.js.map +1 -1
  73. package/dist/tools/web_fetch.d.ts +10 -0
  74. package/dist/tools/web_fetch.d.ts.map +1 -1
  75. package/dist/tools/web_fetch.js +48 -16
  76. package/dist/tools/web_fetch.js.map +1 -1
  77. package/dist/web/assets/lisa-mascot.png +0 -0
  78. package/dist/web/island.d.ts +12 -0
  79. package/dist/web/island.d.ts.map +1 -0
  80. package/dist/web/island.js +948 -0
  81. package/dist/web/island.js.map +1 -0
  82. package/dist/web/lisa-html.d.ts +29 -0
  83. package/dist/web/lisa-html.d.ts.map +1 -0
  84. package/dist/web/lisa-html.js +2095 -0
  85. package/dist/web/lisa-html.js.map +1 -0
  86. package/dist/web/server.d.ts.map +1 -1
  87. package/dist/web/server.js +86 -1601
  88. package/dist/web/server.js.map +1 -1
  89. package/package.json +13 -1
@@ -0,0 +1,948 @@
1
+ /**
2
+ * Mac Island Phase 1 — web widget served at GET /island.
3
+ *
4
+ * Designed to be opened in a tiny always-on-top browser window (Arc small
5
+ * window, Vivaldi PWA, Safari split). Subscribes to /events for live mood +
6
+ * idle pulses, polls /api/island/ping for richer state (current desire,
7
+ * unread flag). See docs/MAC_ISLAND_PLAN.md.
8
+ *
9
+ * No build step, no framework — single string of inline HTML/CSS/JS.
10
+ */
11
+ export const ISLAND_HTML = `<!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
+ <title>Lisa · island</title>
17
+ <link rel="manifest" href="/manifest.webmanifest">
18
+ <style>
19
+ :root {
20
+ color-scheme: dark;
21
+ --bg: rgba(8, 12, 24, 0.92);
22
+ --bg-strong: rgba(8, 12, 24, 0.96);
23
+ --fg: #e4e4e6;
24
+ --fg-dim: #9ba3b8;
25
+ --fg-faint: #6b7280;
26
+ --accent: #6ad4ff;
27
+ --accent-warm: #ffd066;
28
+ --accent-dream: #b487ff;
29
+ --accent-claude: #ff8c42;
30
+ --border: rgba(255, 255, 255, 0.06);
31
+ }
32
+ html, body {
33
+ margin: 0;
34
+ padding: 0;
35
+ background: transparent;
36
+ overflow: hidden;
37
+ height: 100vh;
38
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
39
+ -webkit-font-smoothing: antialiased;
40
+ color: var(--fg);
41
+ user-select: none;
42
+ cursor: default;
43
+ }
44
+ body {
45
+ display: flex;
46
+ flex-direction: column;
47
+ align-items: center;
48
+ padding: 4px 8px;
49
+ }
50
+
51
+ #pill {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: 8px;
55
+ background: var(--bg);
56
+ border: 1px solid var(--border);
57
+ border-radius: 22px;
58
+ padding: 5px 14px 5px 5px;
59
+ backdrop-filter: blur(20px);
60
+ -webkit-backdrop-filter: blur(20px);
61
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
62
+ cursor: pointer;
63
+ transition: transform 200ms ease, opacity 200ms ease;
64
+ max-width: 280px;
65
+ }
66
+ #pill:hover { transform: translateY(1px); }
67
+
68
+ /* Avatar is an <img> not a background-image — more reliable in
69
+ WKWebView and lets us crop into the face via object-position.
70
+ The 512×512 source has ~15% transparent padding around the
71
+ character; we scale up via object-fit + anchor toward the top
72
+ so the face dominates the small circle.
73
+ pointer-events: none + draggable=false so the img never steals
74
+ or hijacks mouse events from the pill (HTML <img> default is
75
+ draggable, which interferes with our Swift-side click/drag
76
+ resolution). */
77
+ #avatar {
78
+ width: 36px;
79
+ height: 36px;
80
+ border-radius: 50%;
81
+ object-fit: cover;
82
+ object-position: 50% 22%;
83
+ background: #15192a;
84
+ flex-shrink: 0;
85
+ image-rendering: pixelated;
86
+ border: 1px solid rgba(255, 255, 255, 0.10);
87
+ box-shadow: 0 0 0 2px rgba(106, 212, 255, 0.10);
88
+ pointer-events: none;
89
+ -webkit-user-drag: none;
90
+ user-select: none;
91
+ }
92
+
93
+ #label {
94
+ font-size: 13px;
95
+ font-weight: 600;
96
+ letter-spacing: 0.02em;
97
+ color: var(--fg);
98
+ }
99
+
100
+ #dot {
101
+ width: 7px;
102
+ height: 7px;
103
+ border-radius: 50%;
104
+ background: transparent;
105
+ flex-shrink: 0;
106
+ }
107
+ #dot.thinking { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
108
+ #dot.dreaming { background: var(--accent-dream); animation: pulse 2.4s ease-in-out infinite; }
109
+ #dot.unread { background: var(--accent-warm); }
110
+ /* Phase 2: pill dot reflects the strongest signal across all
111
+ Claude sessions. */
112
+ #dot.claude-working { background: var(--accent-claude); animation: pulse 1.8s ease-in-out infinite; }
113
+ #dot.claude-waiting { background: var(--accent-claude); } /* solid — "needs you" */
114
+ #dot.claude-error { background: #ff5577; animation: pulse 0.8s ease-in-out infinite; }
115
+ #dot.offline { background: var(--fg-faint); }
116
+
117
+ @keyframes pulse {
118
+ 0%, 100% { opacity: 0.35; }
119
+ 50% { opacity: 1; }
120
+ }
121
+
122
+ /* Expanded panel — appears below the pill on hover/click.
123
+ The native LisaIsland.app window is a fixed 360×440 pt; the pill
124
+ takes the top ~58pt (height + 8pt margin around). The expand
125
+ panel fills the rest. When content (long ★ reflection + many
126
+ active Claude sessions + their state trails when row-open)
127
+ exceeds that, the panel scrolls internally rather than letting
128
+ anything clip out of the window. */
129
+ #expand {
130
+ margin-top: 10px;
131
+ width: 336px;
132
+ max-height: calc(100vh - 70px);
133
+ overflow-y: auto;
134
+ overscroll-behavior: contain;
135
+ background: var(--bg-strong);
136
+ border: 1px solid var(--border);
137
+ border-radius: 18px;
138
+ padding: 16px 18px;
139
+ backdrop-filter: blur(20px);
140
+ -webkit-backdrop-filter: blur(20px);
141
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
142
+ font-size: 12.5px;
143
+ line-height: 1.55;
144
+ box-sizing: border-box;
145
+ opacity: 0;
146
+ transform: translateY(-4px);
147
+ pointer-events: none;
148
+ transition: opacity 200ms ease, transform 200ms ease;
149
+ }
150
+ body.expanded #expand {
151
+ opacity: 1;
152
+ transform: none;
153
+ pointer-events: auto;
154
+ }
155
+ /* Subtle scrollbar — visible only while scrolling/hovering. The
156
+ default WKWebView scrollbar is too chunky for a 336px panel. */
157
+ #expand::-webkit-scrollbar { width: 6px; }
158
+ #expand::-webkit-scrollbar-track { background: transparent; }
159
+ #expand::-webkit-scrollbar-thumb {
160
+ background: rgba(255, 255, 255, 0.10);
161
+ border-radius: 3px;
162
+ }
163
+ #expand::-webkit-scrollbar-thumb:hover {
164
+ background: rgba(255, 255, 255, 0.20);
165
+ }
166
+
167
+ /* Stack section blocks with consistent vertical rhythm. */
168
+ #expand > div + div { margin-top: 14px; }
169
+
170
+ .section-label {
171
+ color: var(--accent);
172
+ font-weight: 600;
173
+ text-transform: uppercase;
174
+ letter-spacing: 0.10em;
175
+ font-size: 10.5px;
176
+ margin-bottom: 8px;
177
+ }
178
+ .section-body {
179
+ color: var(--fg-dim);
180
+ word-wrap: break-word;
181
+ }
182
+
183
+ #idle-section { display: none; }
184
+ body.has-unread #idle-section { display: block; }
185
+ #idle-body {
186
+ background: rgba(255, 208, 102, 0.07);
187
+ border-left: 2px solid var(--accent-warm);
188
+ padding: 8px 12px;
189
+ border-radius: 6px;
190
+ color: var(--fg);
191
+ /* No inner max-height — the outer #expand panel scrolls if total
192
+ content overflows the window. One scrollbar, not nested. */
193
+ white-space: pre-wrap;
194
+ }
195
+
196
+ /* Claude Code section — appears when there's active Claude Code activity */
197
+ #claude-section { display: none; }
198
+ body.has-claude #claude-section { display: block; }
199
+ #claude-section .section-label { color: var(--accent-claude); }
200
+ #claude-list {
201
+ list-style: none;
202
+ padding: 4px 0;
203
+ margin: 0;
204
+ border-left: 2px solid var(--accent-claude);
205
+ background: rgba(255, 140, 66, 0.06);
206
+ border-radius: 6px;
207
+ /* No inner overflow either — outer #expand scrolls. Avoids the
208
+ nested-scrollbar UX where the user scrolls inside this card by
209
+ accident and can't reach the action buttons below. */
210
+ }
211
+ #claude-list li {
212
+ padding: 8px 12px;
213
+ color: var(--fg);
214
+ font-size: 11.5px;
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 4px;
218
+ cursor: pointer;
219
+ transition: background 120ms ease;
220
+ }
221
+ #claude-list li:hover { background: rgba(255, 140, 66, 0.10); }
222
+ #claude-list li + li { border-top: 1px solid rgba(255, 140, 66, 0.10); }
223
+ /* Row "head" — the pip + project + relative-time line. Always rendered.
224
+ Stays as a horizontal flex strip even when the row is expanded; the
225
+ trail + actions render BELOW it because the parent <li> is flex-column. */
226
+ #claude-list .head {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 10px;
230
+ }
231
+ #claude-list .proj { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
232
+ #claude-list .when { color: var(--fg-dim); flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 11px; }
233
+ #claude-list .empty { padding: 8px 12px; color: var(--fg-faint); font-style: italic; }
234
+
235
+ /* Phase 2 — per-session state pip prefix */
236
+ #claude-list .pip {
237
+ width: 7px;
238
+ height: 7px;
239
+ border-radius: 50%;
240
+ flex-shrink: 0;
241
+ background: var(--fg-faint);
242
+ margin-right: 4px;
243
+ }
244
+ #claude-list .pip.working { background: var(--accent-claude); animation: pulse 1.8s ease-in-out infinite; }
245
+ #claude-list .pip.waiting { background: var(--accent-claude); }
246
+ #claude-list .pip.error { background: #ff5577; }
247
+ #claude-list .pip.unknown { background: var(--fg-faint); }
248
+
249
+ /* Phase 3 — state transition trail, shown below the row head when
250
+ the parent <li> has the .row-open class. */
251
+ #claude-list .trail {
252
+ display: none;
253
+ margin: 4px 0 0 14px;
254
+ padding: 6px 0 2px;
255
+ border-top: 1px dashed rgba(255, 140, 66, 0.18);
256
+ font-size: 10px;
257
+ color: var(--fg-faint);
258
+ line-height: 1.7;
259
+ word-spacing: 0.05em;
260
+ }
261
+ #claude-list li.row-open .trail { display: block; }
262
+
263
+ /* Phase 3.5 — inline action row when the session is expanded */
264
+ #claude-list .actions {
265
+ display: none;
266
+ margin: 6px 0 0 14px;
267
+ gap: 6px;
268
+ flex-wrap: wrap;
269
+ }
270
+ #claude-list li.row-open .actions { display: flex; }
271
+ #claude-list .actions button {
272
+ flex: 0 0 auto;
273
+ background: rgba(255, 140, 66, 0.10);
274
+ border: 1px solid rgba(255, 140, 66, 0.22);
275
+ color: var(--fg);
276
+ padding: 4px 8px;
277
+ border-radius: 6px;
278
+ font-size: 10.5px;
279
+ cursor: pointer;
280
+ font-family: inherit;
281
+ }
282
+ #claude-list .actions button:hover { background: rgba(255, 140, 66, 0.16); }
283
+ #claude-list .actions button:disabled { opacity: 0.35; cursor: not-allowed; }
284
+ #claude-list .trail .tdot {
285
+ display: inline-block;
286
+ width: 5px;
287
+ height: 5px;
288
+ border-radius: 50%;
289
+ margin-right: 4px;
290
+ vertical-align: 0;
291
+ background: var(--fg-faint);
292
+ }
293
+ #claude-list .trail .tdot.working { background: var(--accent-claude); }
294
+ #claude-list .trail .tdot.waiting { background: var(--accent-claude); opacity: 0.7; }
295
+ #claude-list .trail .tdot.error { background: #ff5577; }
296
+ /* (older stub removed — .head is a real flex strip now, see above) */
297
+
298
+ /* Phase 3 — notification opt-in chip */
299
+ #notify-cta {
300
+ display: none;
301
+ margin-top: 10px;
302
+ padding: 8px 12px;
303
+ border-radius: 10px;
304
+ background: rgba(255, 140, 66, 0.12);
305
+ border: 1px solid rgba(255, 140, 66, 0.30);
306
+ font-size: 11px;
307
+ color: var(--fg);
308
+ text-align: center;
309
+ cursor: pointer;
310
+ transition: background 120ms ease;
311
+ }
312
+ #notify-cta:hover { background: rgba(255, 140, 66, 0.18); }
313
+ body.notify-default #notify-cta { display: block; }
314
+
315
+ #actions {
316
+ display: flex;
317
+ gap: 8px;
318
+ }
319
+ button {
320
+ flex: 1;
321
+ background: rgba(255, 255, 255, 0.05);
322
+ border: 1px solid var(--border);
323
+ color: var(--fg);
324
+ padding: 8px 12px;
325
+ border-radius: 10px;
326
+ font-size: 11.5px;
327
+ cursor: pointer;
328
+ font-family: inherit;
329
+ transition: background 150ms ease;
330
+ }
331
+ button:hover { background: rgba(255, 255, 255, 0.10); }
332
+ button:active { background: rgba(255, 255, 255, 0.15); }
333
+ button.muted { opacity: 0.5; }
334
+
335
+ /* Offline state — desaturate + dim */
336
+ body.offline #avatar { filter: grayscale(1); opacity: 0.5; }
337
+ body.offline #label { color: var(--fg-faint); }
338
+ </style>
339
+ </head>
340
+ <body>
341
+ <div id="pill" role="button" tabindex="0" aria-label="Lisa island">
342
+ <img id="avatar" alt="" draggable="false" src="/assets/lisa/neutral.png" />
343
+ <div id="label">Lisa</div>
344
+ <div id="dot" aria-hidden="true"></div>
345
+ </div>
346
+ <div id="expand" role="region" aria-label="Lisa status detail">
347
+ <div id="desire-section">
348
+ <div class="section-label">currently wanting</div>
349
+ <div class="section-body" id="desire-body">—</div>
350
+ </div>
351
+ <div id="idle-section">
352
+ <div class="section-label">★ while you were away</div>
353
+ <div id="idle-body"></div>
354
+ </div>
355
+ <div id="claude-section">
356
+ <div class="section-label">claude code · <span id="claude-count">0</span> active</div>
357
+ <ul id="claude-list"></ul>
358
+ <div id="notify-cta" role="button" tabindex="0">🔔 Notify me when Claude is waiting</div>
359
+ </div>
360
+ <div id="actions">
361
+ <button id="btn-open">Open chat</button>
362
+ <button id="btn-dismiss" class="muted">Dismiss ★</button>
363
+ </div>
364
+ </div>
365
+
366
+ <script>
367
+ (() => {
368
+ const pill = document.getElementById('pill');
369
+ const avatar = document.getElementById('avatar');
370
+ const dot = document.getElementById('dot');
371
+ const expand = document.getElementById('expand');
372
+ const desireBody = document.getElementById('desire-body');
373
+ const idleBody = document.getElementById('idle-body');
374
+ const claudeList = document.getElementById('claude-list');
375
+ const claudeCount = document.getElementById('claude-count');
376
+ const notifyCta = document.getElementById('notify-cta');
377
+ const btnOpen = document.getElementById('btn-open');
378
+ const btnDismiss = document.getElementById('btn-dismiss');
379
+ const body = document.body;
380
+
381
+ const state = {
382
+ mood: 'neutral',
383
+ online: false,
384
+ unread: false,
385
+ idleText: '',
386
+ desire: null,
387
+ thinking: false,
388
+ dreaming: false,
389
+ claudeSessions: [], // [{project, sessionId, lastMtime}, …]
390
+ };
391
+
392
+ // 30-min activity window matches the watcher's ACTIVE_WINDOW_MS.
393
+ const CLAUDE_ACTIVE_WINDOW_MS = 30 * 60 * 1000;
394
+
395
+ // Phase 3 — in-memory state transition history per session.
396
+ // Map<sessionId, [{state, reason, ts}, …]> capped at MAX_HISTORY.
397
+ // Pure UI memory; not persisted, not transmitted.
398
+ const stateHistory = new Map();
399
+ const MAX_HISTORY = 8;
400
+ // Per-session opened-state for the inline trail in the expand list.
401
+ const rowOpen = new Set();
402
+
403
+ function recordStateHistory(sessionId, info) {
404
+ let h = stateHistory.get(sessionId);
405
+ if (!h) { h = []; stateHistory.set(sessionId, h); }
406
+ const last = h[h.length - 1];
407
+ if (last && last.state === info.state && last.reason === info.reason) {
408
+ // Same state, just refresh timestamp — collapse repeats.
409
+ last.ts = info.lastMtime;
410
+ return false;
411
+ }
412
+ h.push({ state: info.state, reason: info.reason, ts: info.lastMtime });
413
+ while (h.length > MAX_HISTORY) h.shift();
414
+ return true; // transition happened
415
+ }
416
+
417
+ // ── Phase 3 — notifications (native via LisaIsland.app, or web fallback)
418
+ // Fires "Claude is waiting in <project>" alerts when a session
419
+ // transitions INTO waiting. Throttled per session so a flaky tool
420
+ // that bounces between waiting/working doesn't spam.
421
+ //
422
+ // Phase 3.5: when running inside LisaIsland.app, we delegate to the
423
+ // native UNUserNotificationCenter via postMessage — better permission
424
+ // flow, integrates with macOS Focus / DnD. In a plain browser tab we
425
+ // fall back to the Notification API.
426
+ const NOTIFY_THROTTLE_MS = 60_000;
427
+ const lastNotifyAt = new Map();
428
+ const hasBridge = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island);
429
+
430
+ function notifPermission() {
431
+ if (hasBridge) return 'granted'; // native side asks the user; we just request and trust
432
+ return ('Notification' in window) ? Notification.permission : 'unsupported';
433
+ }
434
+
435
+ function refreshNotifyCta() {
436
+ // The 🔔 chip is only needed when running in a browser AND
437
+ // permission hasn't been answered yet. Native bridge handles its
438
+ // own permission prompt.
439
+ if (hasBridge) {
440
+ body.classList.remove('notify-default');
441
+ return;
442
+ }
443
+ body.classList.toggle('notify-default', notifPermission() === 'default');
444
+ }
445
+
446
+ function requestNotificationPermission() {
447
+ if (hasBridge) {
448
+ window.webkit.messageHandlers.island.postMessage({ type: 'ensure_notify_permission' });
449
+ refreshNotifyCta();
450
+ return;
451
+ }
452
+ if (!('Notification' in window) || Notification.permission !== 'default') {
453
+ refreshNotifyCta();
454
+ return;
455
+ }
456
+ Notification.requestPermission().then(() => refreshNotifyCta()).catch(() => {});
457
+ }
458
+
459
+ function maybeNotifyWaiting(prevState, info) {
460
+ if (info.state !== 'waiting') return;
461
+ if (prevState === 'waiting') return; // already in this state
462
+ const last = lastNotifyAt.get(info.sessionId) || 0;
463
+ if (Date.now() - last < NOTIFY_THROTTLE_MS) return;
464
+ lastNotifyAt.set(info.sessionId, Date.now());
465
+ const reasonLabel = info.stateReason === 'permission' ? 'needs permission' : 'is waiting';
466
+ const title = 'Claude ' + reasonLabel + ' in ' + info.project;
467
+ const bodyText = info.sessionId.slice(0, 8) + ' · click to open Lisa';
468
+ if (hasBridge) {
469
+ window.webkit.messageHandlers.island.postMessage({
470
+ type: 'notify',
471
+ title: title,
472
+ body: bodyText,
473
+ sessionId: info.sessionId,
474
+ });
475
+ return;
476
+ }
477
+ if (notifPermission() !== 'granted') return;
478
+ try {
479
+ const n = new Notification(title, {
480
+ body: bodyText,
481
+ tag: 'lisa-claude-' + info.sessionId,
482
+ icon: '/assets/lisa-mascot.png',
483
+ silent: false,
484
+ });
485
+ n.onclick = () => { window.focus(); window.open('/', '_blank'); n.close(); };
486
+ } catch (_) { /* unsupported, ignore */ }
487
+ }
488
+ function recentSessions() {
489
+ const cutoff = Date.now() - CLAUDE_ACTIVE_WINDOW_MS;
490
+ return state.claudeSessions.filter(
491
+ (s) => new Date(s.lastMtime).getTime() >= cutoff
492
+ );
493
+ }
494
+ /**
495
+ * Phase 2: aggregate Claude state for the pill dot. Priority is
496
+ * "loudest signal wins": an error anywhere dominates everything;
497
+ * otherwise a "waiting" session beats a "working" session (because
498
+ * "waiting" means Claude needs the user — more attention-worthy
499
+ * than "working" which is passive observing).
500
+ */
501
+ function aggregateClaudeState() {
502
+ const recent = recentSessions();
503
+ if (recent.length === 0) return null;
504
+ if (recent.some((s) => s.state === 'error')) return 'error';
505
+ if (recent.some((s) => s.state === 'waiting')) return 'waiting';
506
+ if (recent.some((s) => s.state === 'working')) return 'working';
507
+ return null;
508
+ }
509
+
510
+ function setAvatar(slug) {
511
+ if (!slug) return;
512
+ state.mood = slug;
513
+ // Use the <img> src attribute — far more reliable than CSS
514
+ // background-image in WKWebView, and lets the browser's standard
515
+ // image cache + retry logic do its thing.
516
+ avatar.src = '/assets/lisa/' + encodeURIComponent(slug) + '.png';
517
+ }
518
+
519
+ function refreshDot() {
520
+ dot.className = '';
521
+ // Priority: LISA's own state (offline / thinking / dreaming / unread)
522
+ // always wins over the Claude-Code-monitor indicator — the pill is
523
+ // primarily about her, the Claude dot is a quieter "by the way".
524
+ if (!state.online) { dot.classList.add('offline'); return; }
525
+ if (state.thinking) { dot.classList.add('thinking'); return; }
526
+ if (state.dreaming) { dot.classList.add('dreaming'); return; }
527
+ if (state.unread) { dot.classList.add('unread'); return; }
528
+ const claude = aggregateClaudeState();
529
+ if (claude === 'error') { dot.classList.add('claude-error'); return; }
530
+ if (claude === 'waiting') { dot.classList.add('claude-waiting'); return; }
531
+ if (claude === 'working') { dot.classList.add('claude-working'); return; }
532
+ }
533
+
534
+ function refreshPanel() {
535
+ body.classList.toggle('offline', !state.online);
536
+ body.classList.toggle('has-unread', state.unread);
537
+ body.classList.toggle('has-claude', state.claudeSessions.length > 0);
538
+ desireBody.textContent = state.desire || '(nothing actively pursued)';
539
+ idleBody.textContent = state.idleText || '';
540
+ btnDismiss.classList.toggle('muted', !state.unread);
541
+ btnDismiss.disabled = !state.unread;
542
+ renderClaudeList();
543
+ }
544
+
545
+ function relativeTime(iso) {
546
+ const ms = Date.now() - new Date(iso).getTime();
547
+ if (ms < 30_000) return 'just now';
548
+ if (ms < 60_000) return Math.round(ms / 1000) + 's ago';
549
+ if (ms < 3600_000) return Math.round(ms / 60_000) + 'm ago';
550
+ return Math.round(ms / 3600_000) + 'h ago';
551
+ }
552
+
553
+ function renderClaudeList() {
554
+ const recent = recentSessions();
555
+ claudeCount.textContent = String(recent.length);
556
+ while (claudeList.firstChild) claudeList.removeChild(claudeList.firstChild);
557
+ if (recent.length === 0) {
558
+ const li = document.createElement('li');
559
+ li.className = 'empty';
560
+ li.textContent = '(idle)';
561
+ claudeList.appendChild(li);
562
+ return;
563
+ }
564
+ // Sort: errors first, then waiting, then working, then by mtime.
565
+ const stateRank = { error: 0, waiting: 1, working: 2, unknown: 3 };
566
+ const rows = recent.slice().sort((a, b) => {
567
+ const ra = stateRank[a.state] ?? 9;
568
+ const rb = stateRank[b.state] ?? 9;
569
+ if (ra !== rb) return ra - rb;
570
+ return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();
571
+ }).slice(0, 5);
572
+ for (const s of rows) {
573
+ const li = document.createElement('li');
574
+ if (rowOpen.has(s.sessionId)) li.classList.add('row-open');
575
+ // pip + project + relative-time render as a single horizontal
576
+ // .head strip. The trail + actions render BELOW the head when
577
+ // the row is open (li is flex-column).
578
+ const head = document.createElement('div');
579
+ head.className = 'head';
580
+ const pip = document.createElement('span');
581
+ pip.className = 'pip ' + (s.state || 'unknown');
582
+ const proj = document.createElement('span');
583
+ proj.className = 'proj';
584
+ proj.textContent = s.project;
585
+ const when = document.createElement('span');
586
+ when.className = 'when';
587
+ when.textContent = relativeTime(s.lastMtime);
588
+ head.appendChild(pip);
589
+ head.appendChild(proj);
590
+ head.appendChild(when);
591
+ li.appendChild(head);
592
+
593
+ // Phase 3 — collapsible state-transition trail
594
+ const trail = document.createElement('div');
595
+ trail.className = 'trail';
596
+ renderTrail(trail, s);
597
+ li.appendChild(trail);
598
+
599
+ // Phase 3.5 — action buttons (Open in Finder / Copy resume)
600
+ const actions = document.createElement('div');
601
+ actions.className = 'actions';
602
+ renderActions(actions, s);
603
+ li.appendChild(actions);
604
+
605
+ li.title = s.state + (s.stateReason ? ' (' + s.stateReason + ')' : '')
606
+ + ' · ' + s.sessionId
607
+ + '\\nclick: expand timeline · double-click: copy sessionId';
608
+ li.addEventListener('click', () => {
609
+ if (rowOpen.has(s.sessionId)) rowOpen.delete(s.sessionId);
610
+ else rowOpen.add(s.sessionId);
611
+ renderClaudeList();
612
+ });
613
+ li.addEventListener('dblclick', async (ev) => {
614
+ ev.stopPropagation();
615
+ try { await navigator.clipboard.writeText(s.sessionId); } catch (_) {}
616
+ });
617
+ claudeList.appendChild(li);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Phase 3.5 — render the inline action buttons for one Claude session.
623
+ * Each session has a cwd (from .cwd top-level field in the jsonl)
624
+ * and a sessionId. We expose two actions:
625
+ * - Open in Finder — opens the cwd folder
626
+ * - Copy resume cmd — clipboard: cd "<cwd>" && claude --resume <sid>
627
+ */
628
+ function renderActions(container, s) {
629
+ const cwd = s.cwd || '';
630
+ const hasCwd = cwd.startsWith('/');
631
+
632
+ const openBtn = document.createElement('button');
633
+ openBtn.type = 'button';
634
+ openBtn.textContent = '📁 Open folder';
635
+ openBtn.disabled = !hasCwd;
636
+ openBtn.title = hasCwd ? cwd : 'No cwd recorded in this session';
637
+ openBtn.addEventListener('click', (e) => {
638
+ e.stopPropagation();
639
+ if (!hasCwd) return;
640
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island) {
641
+ window.webkit.messageHandlers.island.postMessage({
642
+ type: 'open_path',
643
+ path: cwd,
644
+ });
645
+ } else {
646
+ // Browser fallback: copy the path so the user can paste in Finder.
647
+ navigator.clipboard.writeText(cwd).catch(() => {});
648
+ }
649
+ });
650
+ container.appendChild(openBtn);
651
+
652
+ const copyBtn = document.createElement('button');
653
+ copyBtn.type = 'button';
654
+ copyBtn.textContent = '📋 Resume cmd';
655
+ copyBtn.disabled = !hasCwd;
656
+ const cmd = hasCwd
657
+ ? 'cd ' + JSON.stringify(cwd) + ' && claude --resume ' + s.sessionId
658
+ : 'claude --resume ' + s.sessionId;
659
+ copyBtn.title = cmd;
660
+ copyBtn.addEventListener('click', async (e) => {
661
+ e.stopPropagation();
662
+ try {
663
+ await navigator.clipboard.writeText(cmd);
664
+ const orig = copyBtn.textContent;
665
+ copyBtn.textContent = '✓ copied';
666
+ setTimeout(() => { copyBtn.textContent = orig; }, 1200);
667
+ } catch (_) {}
668
+ });
669
+ container.appendChild(copyBtn);
670
+ }
671
+
672
+ function renderTrail(container, s) {
673
+ const h = stateHistory.get(s.sessionId) || [];
674
+ if (h.length === 0) {
675
+ container.textContent = '(no transitions recorded yet)';
676
+ return;
677
+ }
678
+ // Render newest first so the right-most reads as "right now".
679
+ const ordered = h.slice();
680
+ for (let i = 0; i < ordered.length; i++) {
681
+ const entry = ordered[i];
682
+ const tdot = document.createElement('span');
683
+ tdot.className = 'tdot ' + (entry.state || 'unknown');
684
+ container.appendChild(tdot);
685
+ const span = document.createElement('span');
686
+ span.textContent = entry.state + ' · ' + relativeTime(entry.ts);
687
+ container.appendChild(span);
688
+ if (i < ordered.length - 1) {
689
+ const sep = document.createElement('span');
690
+ sep.textContent = ' → ';
691
+ sep.style.color = 'rgba(255,255,255,0.15)';
692
+ container.appendChild(sep);
693
+ }
694
+ }
695
+ }
696
+
697
+ function expandPanel(open) {
698
+ if (body.classList.contains('expanded') === open) return;
699
+ body.classList.toggle('expanded', open);
700
+ // Tell the native container (LisaIsland.app, Phase 2.2+) so it can
701
+ // resize its NSWindow — the pill window is sized just for the pill
702
+ // when collapsed, and grows to host the expand panel on open.
703
+ // Falls back gracefully when running in a plain browser tab.
704
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island) {
705
+ window.webkit.messageHandlers.island.postMessage({
706
+ type: open ? 'expand' : 'collapse'
707
+ });
708
+ }
709
+ }
710
+
711
+ // ── Interaction ────────────────────────────────────────────────────
712
+ //
713
+ // When running inside LisaIsland.app (Phase 2.1+), the native window
714
+ // owns drag and click-vs-drag resolution: Swift's IslandWindow
715
+ // intercepts mouseDown in sendEvent and runs a synchronous
716
+ // mouseDragged loop. If movement > 4px → drag (setFrameOrigin each
717
+ // tick, no IPC roundtrip). If no movement → Swift synthesizes
718
+ // pill.click() here so this click handler still fires.
719
+ //
720
+ // In a plain browser tab there's no native container — the click
721
+ // handler runs normally. Hover-to-expand also works in both modes:
722
+ // when the native window is in "passthrough" state for the
723
+ // non-pill area, mouseenter still fires on the WKWebView once the
724
+ // cursor crosses INTO the pill region.
725
+
726
+ let hoverTimer = null;
727
+ pill.addEventListener('mouseenter', () => {
728
+ clearTimeout(hoverTimer);
729
+ hoverTimer = setTimeout(() => expandPanel(true), 250);
730
+ });
731
+ pill.addEventListener('mouseleave', () => {
732
+ clearTimeout(hoverTimer);
733
+ // Don't collapse if the mouse just moved into the expand panel.
734
+ setTimeout(() => {
735
+ if (!expand.matches(':hover')) expandPanel(false);
736
+ }, 200);
737
+ });
738
+ expand.addEventListener('mouseleave', () => expandPanel(false));
739
+
740
+ pill.addEventListener('click', (e) => {
741
+ e.preventDefault();
742
+ expandPanel(!body.classList.contains('expanded'));
743
+ });
744
+ pill.addEventListener('dblclick', (e) => {
745
+ e.preventDefault();
746
+ openFull();
747
+ });
748
+
749
+ function openFull() {
750
+ // If a Swift container is present (Phase 2), prefer to delegate.
751
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island) {
752
+ window.webkit.messageHandlers.island.postMessage({ type: 'open_full_gui' });
753
+ } else {
754
+ window.open('/', '_blank');
755
+ }
756
+ }
757
+ btnOpen.addEventListener('click', (e) => { e.stopPropagation(); openFull(); });
758
+ btnDismiss.addEventListener('click', async (e) => {
759
+ e.stopPropagation();
760
+ if (!state.unread) return;
761
+ try { await fetch('/api/island/dismiss-unread', { method: 'POST' }); } catch (_) {}
762
+ state.unread = false;
763
+ state.idleText = '';
764
+ refreshDot();
765
+ refreshPanel();
766
+ });
767
+
768
+ // ── Server polling for richer state ────────────────────────────────
769
+ let backoff = 5_000;
770
+ async function pollPing() {
771
+ try {
772
+ const r = await fetch('/api/island/ping', { cache: 'no-store' });
773
+ if (!r.ok) throw new Error('not ok');
774
+ const j = await r.json();
775
+ state.online = !!j.online;
776
+ state.unread = !!j.has_unread_idle_message;
777
+ state.idleText = j.last_idle_message_text || '';
778
+ state.desire = j.current_desire || null;
779
+ if (j.mood) setAvatar(j.mood);
780
+ backoff = 5_000;
781
+ } catch (_) {
782
+ state.online = false;
783
+ }
784
+ refreshDot();
785
+ refreshPanel();
786
+ }
787
+
788
+ // ── SSE subscription for instant pulses ────────────────────────────
789
+ let es = null;
790
+ function subscribe() {
791
+ try { if (es) es.close(); } catch (_) {}
792
+ es = new EventSource('/events');
793
+ es.addEventListener('open', () => {
794
+ state.online = true;
795
+ refreshDot();
796
+ refreshPanel();
797
+ });
798
+ es.addEventListener('message', (ev) => {
799
+ let m;
800
+ try { m = JSON.parse(ev.data); } catch (_) { return; }
801
+ switch (m.type) {
802
+ case 'mood':
803
+ setAvatar(m.slug);
804
+ break;
805
+ case 'chat_start':
806
+ state.thinking = true;
807
+ refreshDot();
808
+ break;
809
+ case 'chat_end':
810
+ state.thinking = false;
811
+ refreshDot();
812
+ break;
813
+ case 'idle_start':
814
+ state.dreaming = true;
815
+ refreshDot();
816
+ break;
817
+ case 'idle_done':
818
+ case 'idle_error':
819
+ state.dreaming = false;
820
+ refreshDot();
821
+ break;
822
+ case 'idle_message':
823
+ state.dreaming = false;
824
+ state.unread = true;
825
+ state.idleText = (m.text || '').slice(0, 1000);
826
+ refreshDot();
827
+ refreshPanel();
828
+ // One subtle pulse so a watching user notices.
829
+ document.body.animate(
830
+ [{ opacity: 0.7 }, { opacity: 1 }, { opacity: 0.85 }],
831
+ { duration: 600, iterations: 2 },
832
+ );
833
+ break;
834
+ case 'claude_session_update':
835
+ upsertClaudeSession({
836
+ project: m.projectLabel,
837
+ projectEncoded: m.projectEncoded,
838
+ sessionId: m.sessionId,
839
+ lastMtime: m.ts,
840
+ state: m.state || 'unknown',
841
+ stateReason: m.stateReason || '',
842
+ cwd: m.cwd || '',
843
+ });
844
+ refreshDot();
845
+ refreshPanel();
846
+ break;
847
+ }
848
+ });
849
+ es.addEventListener('error', () => {
850
+ state.online = false;
851
+ refreshDot();
852
+ refreshPanel();
853
+ // Auto-retry: SSE EventSource reconnects on its own, but if the page
854
+ // server is down entirely we'll keep firing 'error' until it's back.
855
+ });
856
+ }
857
+
858
+ // ── Claude Code session helpers ────────────────────────────────────
859
+
860
+ function upsertClaudeSession(s) {
861
+ const idx = state.claudeSessions.findIndex(
862
+ (x) => x.sessionId === s.sessionId
863
+ );
864
+ const prevState = idx >= 0 ? state.claudeSessions[idx].state : null;
865
+ if (idx >= 0) state.claudeSessions[idx] = s;
866
+ else state.claudeSessions.push(s);
867
+ pruneClaudeSessions();
868
+
869
+ // Phase 3 — record transition + maybe notify on entering "waiting".
870
+ const transitioned = recordStateHistory(s.sessionId, {
871
+ state: s.state,
872
+ reason: s.stateReason,
873
+ lastMtime: s.lastMtime,
874
+ });
875
+ if (transitioned) maybeNotifyWaiting(prevState, s);
876
+ }
877
+
878
+ function pruneClaudeSessions() {
879
+ const cutoff = Date.now() - CLAUDE_ACTIVE_WINDOW_MS;
880
+ state.claudeSessions = state.claudeSessions.filter(
881
+ (s) => new Date(s.lastMtime).getTime() >= cutoff
882
+ );
883
+ }
884
+
885
+ async function fetchClaudeSessions() {
886
+ try {
887
+ const r = await fetch('/api/claude/sessions', { cache: 'no-store' });
888
+ if (!r.ok) return;
889
+ const j = await r.json();
890
+ if (Array.isArray(j.sessions)) {
891
+ state.claudeSessions = j.sessions.map((s) => ({
892
+ project: s.project,
893
+ projectEncoded: s.projectEncoded,
894
+ sessionId: s.sessionId,
895
+ lastMtime: s.lastMtime,
896
+ state: s.state || 'unknown',
897
+ stateReason: s.stateReason || '',
898
+ cwd: s.cwd || '',
899
+ }));
900
+ // Phase 3 — seed each session's history with its current state
901
+ // so the trail isn't empty on first open. Doesn't notify (no
902
+ // transition implied by initial load).
903
+ for (const s of state.claudeSessions) {
904
+ recordStateHistory(s.sessionId, {
905
+ state: s.state,
906
+ reason: s.stateReason,
907
+ lastMtime: s.lastMtime,
908
+ });
909
+ }
910
+ }
911
+ } catch (_) {
912
+ // server might not yet have the endpoint (older LISA) — silent
913
+ }
914
+ }
915
+
916
+ // Phase 3 — notification permission opt-in. The CTA is only visible
917
+ // when permission is in the default (un-asked) state.
918
+ notifyCta.addEventListener('click', (e) => {
919
+ e.stopPropagation();
920
+ requestNotificationPermission();
921
+ });
922
+ notifyCta.addEventListener('keydown', (e) => {
923
+ if (e.key === 'Enter' || e.key === ' ') requestNotificationPermission();
924
+ });
925
+
926
+ // ── Bootstrap ──────────────────────────────────────────────────────
927
+ setAvatar('neutral');
928
+ pollPing();
929
+ fetchClaudeSessions().then(() => { refreshDot(); refreshPanel(); });
930
+ subscribe();
931
+ refreshNotifyCta();
932
+ // Lightweight resync — covers cases where SSE silently disconnected
933
+ // (laptop sleep, network blip) and we need to refresh state.
934
+ setInterval(pollPing, 30_000);
935
+ setInterval(fetchClaudeSessions, 60_000);
936
+ // Re-render every 15s so "Xs ago" / "Xm ago" labels stay fresh and
937
+ // stale sessions fall off the list without a fresh update event.
938
+ setInterval(() => {
939
+ pruneClaudeSessions();
940
+ refreshDot();
941
+ refreshPanel();
942
+ }, 15_000);
943
+ })();
944
+ </script>
945
+ </body>
946
+ </html>
947
+ `;
948
+ //# sourceMappingURL=island.js.map