@vale-dinap/lucidly 0.3.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to Lucidly are listed here. The format follows [Keep a Chang
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - **Optional sound alerts**, with the bell next to keep-awake in the top bar: a short sound when a session finishes, asks you something or runs into trouble, in any of your tabs. Off by default. Each event has its own sound, chosen from a dropdown (17 sounds in three groups: gentle ones, questions, and louder alerts such as Alarm, Siren and Buzzer; or None for no sound) and tried with *Test*, and there is a volume slider. *Only when I'm not looking* (on by default) keeps quiet for the session on screen while the page is visible. The sounds are made in the browser, no files. Browsers only allow sound after you interact with a page: after a reload the bell turns amber until your first click or key press.
12
+
13
+ ## [0.4.0] - 2026-11-11
14
+
15
+ ### Added
16
+
17
+ - **Session tabs: several sessions open at once.** Picking a session in the list still replaces the one on screen; to keep both, open it in a new tab with `Ctrl`/`⌘`-click, a middle click, the row's **New tab** button, or the **+** at the end of the tab strip. The strip shows from two tabs on, at the very top of the page, above the top bar. Each tab has a dot in the orb's colours (blue working, amber your turn, green finished, red in trouble, grey quiet), and a count of running subagents. When a tab you're not looking at asks you something, finishes or runs into trouble, it flashes a few times, then glows until you open it, and the page title counts those tabs, like "(2) Lucidly". A session already in a tab is never opened twice: picking it switches to its tab. Each tab keeps your place (view, selection, Glance spotlight, scroll) for when you come back. `[` / `]` go to the previous / next tab, and a middle click on a tab closes it. Tabs survive a reload and a server restart, which reopens their sessions.
18
+ - **Glance names the step too, and says how both stand.** Under the orb, the task in the spotlight and, on a second line, the node in the middle of its chain (the step under way by default, "Step 3 of 5"; `←` / `→` move it, and the line follows: your request, a reply, a round's end, the result). Each line has a status mark: a spinning ring while it runs, a tick when done, a cross when in trouble, a balloon when it asked you, a pause when interrupted, an empty ring for what hasn't started.
19
+ - **Running subagents show in Glance.** While any runs, including in the background after the agent's turn ended, a tag beside the orb names the latest one, with "+N" for the others. Click it for the list of all of them (how long each has run, and the task and step that launched it); pick one and the wheel brings that step into the spotlight, and the side panel, if open, shows the subagent.
20
+
21
+ ### Changed
22
+
23
+ - **Lucidly stops following a session nobody has open.** Closing its tab (or replacing it with another session) stops it at once, unless another page has it too; a session no page has shown for 10 minutes stops by itself. Until now, every session opened kept being followed until the server stopped. A saved snapshot you loaded is kept.
24
+ - **An earlier task in the Glance spotlight centres on its goal**, where it ended up, instead of your request; with the side panel open, the goal is the one selected, and the panel shows the task with its result highlighted.
25
+ - **The System theme has a clearer icon**: the sun and the moon, cut along a diagonal, instead of a monitor. The Dark icon's moon now faces the same way.
26
+
7
27
  ## [0.3.0] - 2026-11-06
8
28
 
9
29
  ### Added
@@ -54,6 +74,8 @@ All notable changes to Lucidly are listed here. The format follows [Keep a Chang
54
74
 
55
75
  First public release.
56
76
 
77
+ [0.5.0]: https://github.com/vale-dinap/lucidly/compare/v0.4.0...v0.5.0
78
+ [0.4.0]: https://github.com/vale-dinap/lucidly/compare/v0.3.0...v0.4.0
57
79
  [0.3.0]: https://github.com/vale-dinap/lucidly/compare/v0.2.0...v0.3.0
58
80
  [0.2.0]: https://github.com/vale-dinap/lucidly/compare/v0.1.0...v0.2.0
59
81
  [0.1.0]: https://github.com/vale-dinap/lucidly/releases/tag/v0.1.0
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [![CI](https://github.com/vale-dinap/lucidly/actions/workflows/ci.yml/badge.svg)](https://github.com/vale-dinap/lucidly/actions/workflows/ci.yml)
4
4
 
5
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vale-dinap/lucidly)
6
+
5
7
  *See what your AI agent is doing, at a glance.*
6
8
 
7
- https://github.com/user-attachments/assets/ff9ff898-4317-4e78-99e4-caac09b620ca
9
+ https://github.com/user-attachments/assets/6ceb52e7-eb58-4223-adba-d6a04d61b263
8
10
 
9
11
  ## Quick start
10
12
 
@@ -52,6 +54,14 @@ Every item in the view, whether a planned step, a subtask or a blocker, is linke
52
54
 
53
55
  All of these show a made-up demo session (`scripts/demo-session.mjs`).
54
56
 
57
+ **Glance**, the default view: how the agent is doing, at a glance. The orb says what it is doing now, and the task under way unfolds into its steps, from your request to the goal, with the steps still to come.
58
+
59
+ ![The Glance view while the agent works: the orb running a command, and the current task unfolded into its steps](docs/media/img_04_glance-view-while-the-agent-works.png)
60
+
61
+ **Glance and the side panel.** Pick any step, here a subagent's, and the panel shows its files, commands and transcript, while Glance stays where it was.
62
+
63
+ ![The Glance view in the light theme, with a subagent step open in the side panel](docs/media/img_05_glance-with-a-subagent-step-open-light-theme.png)
64
+
55
65
  **Drill down.** Open a step in place to see each of its actions, hover one for what it did, and click it for its full output.
56
66
 
57
67
  https://github.com/user-attachments/assets/a8d20d0e-41c5-4ada-a9d5-b9cf6541714f
@@ -134,7 +144,9 @@ In the browser:
134
144
  - Switch views with the buttons at the left of the toolbar under the timeline (it stays on screen while you scroll), or `V` to cycle through *Glance*, *Simple*, *Detailed*, *Files* and **Actions**. In Simple and Detailed, the **filters** next to them narrow the list: *All*, *Failed*, *Recovered*, *Subagents* and *Questions* (the exchanges answered without any work), each with its count. When the toolbar is too narrow (for example with the side panel open), they fold into one menu button showing the current filter, with a red dot if anything is still failed. *Hide Q&A* applies to *All* only, and greys out with the other filters. Search is a magnifier until you click it or press `/`. The flame button (or `H`) turns on **token heat**: tasks and steps glow from green to red by the tokens they used (fresh input, cache writes and output, the way the token meter counts them), relative to the rest of the run, on a log scale so only the real outliers turn red. Tasks and questions are ranked together, steps among steps. It is off by default; hover a glowing node or dot for its tokens. Actions is a flat, chronological list of everything the agent did: commands, edits, reads, searches, subagent calls. Filter by kind, show only *Failed* actions or only *Recovered* ones (each failure is tagged *failed*, or *failed → recovered* in grey), search (`/`), or limit to one task. Each failure shows its actual error line. Select a row to see its full input and output, and *Show in path* to jump back to the step it belongs to. A long shell command is shown in parts: its shell lines, the script it runs (`node - <<'EOF'`, `python -c "…"`, highlighted as JavaScript or Python), and the content it writes to a file (`cat > a.py <<'EOF'`), with *Show the whole command* for the command as recorded. For commands that change files, long lines that wrap continue indented so each real line stands out, and dense JavaScript has *Reformat for reading*: line breaks and indentation for display only, with *Show as written* to go back. *Copy commands* copies the listed shell commands, with failed ones marked. In the side panel, every card has its own **copy button**: your messages, the agent's text, plans and results (as Markdown), thinking, commands and each of their parts (without the `$` prompt; a script as written, even when shown reformatted), inputs, outputs and errors (whole, even when only the first lines are shown), and diffs (as a unified diff). A tick confirms the copy.
135
145
  - The **pin** in the overview's top-right corner (or `P`) keeps the overview on screen too while you scroll. It is pinned by default: scrolled down, it tightens into a compact card (about half the height) that still shows everything: the stats on one line, the timeline, the subagents on a single row (parallel ones are counted on a *+N* marker), the navigator and the legend. It unfolds again back at the top, and what you were reading stays put during the transition. Unpin it to let it scroll away; Lucidly remembers the choice. The arrows button at the right of the toolbar (or `E`) expands or collapses everything.
136
146
  - **Keep awake** (the cup next to the status in the top bar) stops the device from sleeping, like a no-sleep page. Turn it on, and optionally choose *Only while the agent is working*: the device stays awake while a turn runs and may sleep once the work is done and the session is idle (or has had no news for 30 minutes). The cup steams while it is holding the device awake. It uses the browser's Screen Wake Lock, so it works while the Lucidly tab is visible: a hidden or minimised tab lets the device sleep, and it takes effect again when you come back.
147
+ - **Sounds** (the bell next to keep-awake) plays a short sound when a session finishes, asks you something or runs into trouble, in any of your tabs. It is off by default. Each event has its own sound, chosen from a dropdown (17 sounds in three groups: gentle ones, questions, and louder alerts such as Alarm, Siren and Buzzer; or None for no sound) and tried with *Test*, and the volume can be set. With *Only when I'm not looking* (on by default), the session on screen stays quiet while the page is visible. Browsers only play sound after you interact with a page, so after a reload the bell turns amber until your first click or key press.
137
148
  - A session opens at the top, on the overview and its timeline, with the side panel closed until you select something. Whenever you're not at the end of the list, a round **arrow button** floats at the bottom right, with a count of actions that arrived since you scrolled away. Click it (or press `F` or `End`) to jump to the end and follow the agent, keeping the current step in view. If the side panel is closed it stays closed: the list just scrolls along. Scrolling or clicking pauses following. **Open session** (the folder button on the left, the run's title, or `O`) lists the Claude Code sessions of this project or of all projects. All projects are grouped by project folder, each group headed by the folder's name, its session count and when it last changed (a green dot if a session in it is live). This project comes first; groups fold, and the ones open by default are this project's and any with a live session (or all of them, when there are only a few). *Expand all* / *Collapse all* sits at the top, and what you open or close stays that way.
149
+ - **Tabs** let you keep several sessions open. Picking a session replaces the one on screen; `Ctrl`/`⌘`-click it, middle-click it or use its **New tab** button to open it in a new tab instead (the **+** at the end of the tab strip does the same). From two tabs on, a strip at the very top of the page, above the top bar, shows them, each with a dot in the orb's colours and a count of running subagents. A tab you're not looking at that asks you something, finishes or runs into trouble flashes, then glows until you open it, and the page title shows how many do. Picking a session that's already in a tab switches to it. Each tab keeps your place (view, selection, spotlight, scroll). `[` / `]` move between tabs, a middle click closes one, and they come back after a reload. Closing a tab stops following its session; one no page has shown for 10 minutes stops by itself.
138
150
  - The download button, next to *Open session* on the left, saves a **snapshot** of the run as a `.lucidly.json` file, and *Open a saved snapshot…* in the session list reopens one. You don't need this for Claude Code sessions, which reopen straight from their transcripts. It's for keeping a run after its transcript is cleaned up, sharing it with someone who doesn't have the transcript, and runs streamed from other agents through stdin, which have no transcript to reopen.
139
151
 
140
152
  Lucidly only reads. It binds to `127.0.0.1` and never sends anything to the agent.
@@ -0,0 +1,41 @@
1
+ /** Nothing new for this long: a run said to be working has gone quiet (as in the web UI). */
2
+ export const QUIET_MS = 5 * 60 * 1000;
3
+ /** A finished run stays 'done' (rather than 'rest') this long. */
4
+ const RECENT_MS = 30 * 60 * 1000;
5
+ export function runSummary(run, nodes, now = Date.now()) {
6
+ const get = (id) => id === undefined ? undefined : nodes instanceof Map ? nodes.get(id) : nodes[id];
7
+ const kids = (n) => (n?.children ?? []).map(get).filter((c) => !!c);
8
+ // Like the UI's descendants(): children, and inside subtasks, theirs.
9
+ const below = (n, f, acc = []) => {
10
+ for (const c of kids(n)) {
11
+ if (f(c))
12
+ acc.push(c);
13
+ if (c.type === 'subtask')
14
+ below(c, f, acc);
15
+ }
16
+ return acc;
17
+ };
18
+ const openIssue = (s) => below(s, (c) => c.type === 'blocker' && c.status === 'open').length > 0 || (s.stats?.errors ?? 0) - (s.stats?.recovered ?? 0) > 0;
19
+ const age = run.updatedAt ? now - Date.parse(run.updatedAt) : Infinity;
20
+ const stale = age > QUIET_MS;
21
+ const all = nodes instanceof Map ? [...nodes.values()] : Object.values(nodes);
22
+ const agents = stale ? 0 : all.filter((n) => n.type === 'subtask' && n.origin === 'subagent' && n.status === 'active').length;
23
+ const out = (state, question) => ({ state, ...(question !== undefined ? { question } : {}), agents, updatedAt: run.updatedAt });
24
+ const g = get(run.currentGoal) ?? get(run.goals[run.goals.length - 1]);
25
+ if (run.status === 'working') {
26
+ if (stale)
27
+ return out('quiet');
28
+ const step = get(run.currentStep);
29
+ return out(step && openIssue(step) ? 'bad' : 'work');
30
+ }
31
+ if (g?.status === 'waiting')
32
+ return out('paused');
33
+ const ask = g ? below(g, (c) => c.type === 'activity' && c.kind === 'ask' && c.status === 'active')[0] : undefined;
34
+ if (ask)
35
+ return out('you', String(ask.title ?? '').replace(/^Ask:\s*/, ''));
36
+ if (g?.asked)
37
+ return out('you', g.question ?? '');
38
+ return out(age < RECENT_MS ? 'done' : 'rest');
39
+ }
40
+ /** The states that want you: a tab you're not looking at flashes when it turns one of these. */
41
+ export const ATTENTION = new Set(['you', 'bad', 'done']);
@@ -42,11 +42,19 @@ const TYPES = {
42
42
  '.png': 'image/png',
43
43
  '.ico': 'image/x-icon',
44
44
  };
45
+ /** How often tab states are refreshed regardless of new events (they change with time too). */
46
+ const TICK_MS = 15_000;
47
+ /** A run no page has shown or listed for this long is closed (when it can be opened again). */
48
+ export const IDLE_CLOSE_MS = 10 * 60 * 1000;
45
49
  export class LucidlyServer {
46
50
  runs = new Map();
47
51
  byKey = new Map();
48
52
  seq = 0;
49
53
  server = null;
54
+ watchers = new Set();
55
+ /** Open event streams per run, ended when the run closes. */
56
+ streams = new Map();
57
+ ticker = null;
50
58
  port = 0;
51
59
  cwd;
52
60
  wantPort;
@@ -68,6 +76,8 @@ export class LucidlyServer {
68
76
  s.off('error', fail);
69
77
  this.server = s;
70
78
  this.port = s.address().port;
79
+ this.ticker = setInterval(() => this.tick(), TICK_MS);
80
+ this.ticker.unref();
71
81
  ok();
72
82
  });
73
83
  });
@@ -81,6 +91,8 @@ export class LucidlyServer {
81
91
  throw new Error(`No free port from ${this.wantPort}`);
82
92
  }
83
93
  async close() {
94
+ if (this.ticker)
95
+ clearInterval(this.ticker);
84
96
  for (const r of this.runs.values())
85
97
  r.close();
86
98
  this.server?.closeAllConnections?.();
@@ -110,8 +122,66 @@ export class LucidlyServer {
110
122
  r.close();
111
123
  this.runs.delete(id);
112
124
  this.byKey.delete(r.key);
125
+ for (const res of this.streams.get(id) ?? [])
126
+ res.end();
127
+ this.streams.delete(id);
128
+ // Tabs holding it hear that it's gone.
129
+ for (const w of this.watchers)
130
+ if (w.runs.has(id))
131
+ this.pushTabs(w);
113
132
  return true;
114
133
  }
134
+ /** Whether some page (other than `except`) shows the run, or has it in a tab. */
135
+ watched(run, except) {
136
+ if (run.shownBy(except))
137
+ return true;
138
+ for (const w of this.watchers)
139
+ if (w.client !== except && w.runs.has(run.id))
140
+ return true;
141
+ return false;
142
+ }
143
+ /**
144
+ * A tab closed (or switched to another session) on page `client`: the run stops, unless another
145
+ * tab or page still has it.
146
+ */
147
+ release(id, client) {
148
+ const run = this.runs.get(id);
149
+ if (!run)
150
+ return { closed: false };
151
+ if (client)
152
+ for (const w of this.watchers)
153
+ if (w.client === client)
154
+ w.runs.delete(id);
155
+ if (this.watched(run, client))
156
+ return { closed: false };
157
+ return { closed: this.closeRun(id) };
158
+ }
159
+ /** Tells a page's tabs what changed in the states of their runs. */
160
+ pushTabs(w, now = Date.now()) {
161
+ const changed = {};
162
+ for (const id of w.runs) {
163
+ const run = this.runs.get(id);
164
+ const s = run ? run.summary(now) : { gone: true };
165
+ const json = JSON.stringify(s);
166
+ if (w.last.get(id) === json)
167
+ continue;
168
+ w.last.set(id, json);
169
+ changed[id] = s;
170
+ }
171
+ if (Object.keys(changed).length)
172
+ w.send({ runs: changed });
173
+ }
174
+ /** States that change with time alone (working → quiet, done → rest), and closing idle runs. */
175
+ tick(now = Date.now()) {
176
+ for (const w of this.watchers)
177
+ this.pushTabs(w, now);
178
+ for (const run of [...this.runs.values()]) {
179
+ if (this.watched(run))
180
+ run.lastWatched = now;
181
+ else if (run.reopenable && now - run.lastWatched > IDLE_CLOSE_MS)
182
+ this.closeRun(run.id);
183
+ }
184
+ }
115
185
  // ───────────────────────────── HTTP
116
186
  hostOk(req) {
117
187
  const host = (req.headers.host ?? '').toLowerCase();
@@ -151,10 +221,13 @@ export class LucidlyServer {
151
221
  if (!run)
152
222
  return send(res, 404, { error: 'no such run' });
153
223
  const [, , action, arg] = m;
154
- if (!action && req.method === 'DELETE')
155
- return send(res, 200, { closed: this.closeRun(run.id) });
224
+ // With the page's id: only if no other tab or page has the run (see release).
225
+ if (!action && req.method === 'DELETE') {
226
+ const client = url.searchParams.get('client');
227
+ return send(res, 200, client ? this.release(run.id, client) : { closed: this.closeRun(run.id) });
228
+ }
156
229
  if (action === 'stream')
157
- return this.stream(req, res, run);
230
+ return this.stream(req, res, run, url.searchParams.get('client') ?? undefined);
158
231
  if (action === 'raw' && arg) {
159
232
  if (!run.engine.nodes.has(arg))
160
233
  return send(res, 404, { error: 'no such node' });
@@ -182,6 +255,14 @@ export class LucidlyServer {
182
255
  if (path === '/api/runs' && req.method === 'GET') {
183
256
  return send(res, 200, { runs: [...this.runs.values()].map((r) => r.info()) });
184
257
  }
258
+ // A page's session tabs: the states of the runs they hold, live (event "tabs").
259
+ if (path === '/api/tabs/stream' && req.method === 'GET') {
260
+ const client = url.searchParams.get('client');
261
+ if (!client)
262
+ return send(res, 400, { error: 'client required' });
263
+ const ids = (url.searchParams.get('runs') ?? '').split(',').filter(Boolean);
264
+ return this.tabStream(req, res, client, ids);
265
+ }
185
266
  if (path === '/api/sessions' && req.method === 'GET') {
186
267
  const all = url.searchParams.get('scope') === 'all';
187
268
  const sessions = await listSessions({ cwd: all ? undefined : this.cwd, limit: all ? 60 : 30 });
@@ -222,21 +303,44 @@ export class LucidlyServer {
222
303
  }
223
304
  return send(res, 404, { error: 'not found' });
224
305
  }
225
- stream(req, res, run) {
226
- res.writeHead(200, {
227
- 'content-type': 'text/event-stream; charset=utf-8',
228
- 'cache-control': 'no-store',
229
- connection: 'keep-alive',
230
- 'x-accel-buffering': 'no',
231
- });
232
- res.write('retry: 2000\n\n');
306
+ stream(req, res, run, client) {
307
+ sseHead(res);
308
+ let set = this.streams.get(run.id);
309
+ if (!set)
310
+ this.streams.set(run.id, (set = new Set()));
311
+ set.add(res);
233
312
  const unsubscribe = run.subscribe((event, data) => {
234
313
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
235
- });
314
+ }, client);
236
315
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);
237
316
  req.on('close', () => {
238
317
  clearInterval(ping);
239
318
  unsubscribe();
319
+ this.streams.get(run.id)?.delete(res);
320
+ });
321
+ }
322
+ tabStream(req, res, client, ids) {
323
+ sseHead(res);
324
+ // A page has one tab stream: a new one (its tabs changed) replaces the old.
325
+ for (const w of this.watchers)
326
+ if (w.client === client)
327
+ this.watchers.delete(w);
328
+ const w = { client, runs: new Set(ids), last: new Map(), send: (data) => res.write(`event: tabs\ndata: ${JSON.stringify(data)}\n\n`) };
329
+ this.watchers.add(w);
330
+ const offs = ids.map((id) => this.runs.get(id)?.onChange(() => this.pushTabs(w))).filter((f) => !!f);
331
+ this.pushTabs(w);
332
+ const ping = setInterval(() => res.write(': ping\n\n'), 15000);
333
+ req.on('close', () => {
334
+ clearInterval(ping);
335
+ for (const off of offs)
336
+ off();
337
+ this.watchers.delete(w);
338
+ const now = Date.now();
339
+ for (const id of ids) {
340
+ const run = this.runs.get(id);
341
+ if (run)
342
+ run.lastWatched = now;
343
+ }
240
344
  });
241
345
  }
242
346
  async static(res, path) {
@@ -260,6 +364,15 @@ export class LucidlyServer {
260
364
  res.end(body);
261
365
  }
262
366
  }
367
+ function sseHead(res) {
368
+ res.writeHead(200, {
369
+ 'content-type': 'text/event-stream; charset=utf-8',
370
+ 'cache-control': 'no-store',
371
+ connection: 'keep-alive',
372
+ 'x-accel-buffering': 'no',
373
+ });
374
+ res.write('retry: 2000\n\n');
375
+ }
263
376
  function send(res, status, body) {
264
377
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
265
378
  res.end(JSON.stringify(body));
@@ -3,9 +3,12 @@
3
3
  * State lives in memory; saving writes the event log, loading replays it.
4
4
  */
5
5
  import { Engine } from "../core/engine.js";
6
+ import { runSummary } from "../core/summary.js";
6
7
  export const SAVE_FORMAT = 'lucidly-run';
7
8
  export const SAVE_VERSION = 1;
8
9
  const FLUSH_MS = 120;
10
+ /** Listeners to a run's summary (session tabs) hear about changes at most this often. */
11
+ const CHANGE_MS = 250;
9
12
  export class RunSession {
10
13
  id;
11
14
  key;
@@ -14,8 +17,13 @@ export class RunSession {
14
17
  engine = new Engine();
15
18
  events = [];
16
19
  source;
17
- subs = new Set();
20
+ /** Live subscribers, each with the page (client id) it belongs to, when it said. */
21
+ subs = new Map();
18
22
  timer = null;
23
+ changeTimer = null;
24
+ changeSubs = new Set();
25
+ /** When a page last showed or listed this run (for closing runs nobody looks at any more). */
26
+ lastWatched = Date.now();
19
27
  constructor(id, source, opts = {}) {
20
28
  this.id = id;
21
29
  this.source = source;
@@ -35,16 +43,22 @@ export class RunSession {
35
43
  this.engine.applyAll(evs);
36
44
  if (this.subs.size && !this.timer)
37
45
  this.timer = setTimeout(() => this.flush(), FLUSH_MS);
46
+ if (this.changeSubs.size && !this.changeTimer)
47
+ this.changeTimer = setTimeout(() => {
48
+ this.changeTimer = null;
49
+ for (const f of this.changeSubs)
50
+ f();
51
+ }, CHANGE_MS);
38
52
  }
39
53
  flush() {
40
54
  this.timer = null;
41
55
  const patch = this.engine.takePatch();
42
56
  if (!patch)
43
57
  return;
44
- for (const s of this.subs)
58
+ for (const s of this.subs.keys())
45
59
  s('patch', patch);
46
60
  }
47
- subscribe(send) {
61
+ subscribe(send, client) {
48
62
  // Bring existing subscribers up to date first, so the new snapshot and their state agree.
49
63
  if (this.timer) {
50
64
  clearTimeout(this.timer);
@@ -53,9 +67,35 @@ export class RunSession {
53
67
  else {
54
68
  this.engine.takePatch();
55
69
  }
56
- this.subs.add(send);
70
+ this.subs.set(send, client);
71
+ this.lastWatched = Date.now();
57
72
  send('snapshot', this.engine.snapshot());
58
- return () => this.subs.delete(send);
73
+ return () => {
74
+ this.subs.delete(send);
75
+ this.lastWatched = Date.now();
76
+ };
77
+ }
78
+ /** Calls `f` (throttled) whenever new events arrive. */
79
+ onChange(f) {
80
+ this.changeSubs.add(f);
81
+ return () => this.changeSubs.delete(f);
82
+ }
83
+ /** Whether a page other than `except` has this run open (its full stream). */
84
+ shownBy(except) {
85
+ for (const c of this.subs.values())
86
+ if (c === undefined || c !== except)
87
+ return true;
88
+ return false;
89
+ }
90
+ /** Whether it can be opened again from its file (so closing it loses nothing). */
91
+ get reopenable() {
92
+ return !!this.source?.path;
93
+ }
94
+ /** The run's state for its tab, and its name: the session's title, or else its first request. */
95
+ summary(now = Date.now()) {
96
+ const r = this.engine.run;
97
+ const first = r.goals.length ? this.engine.nodes.get(r.goals[0])?.title : undefined;
98
+ return { ...runSummary(r, this.engine.nodes, now), title: r.title ?? first ?? this.label };
59
99
  }
60
100
  info() {
61
101
  const r = this.engine.run;
@@ -69,6 +109,7 @@ export class RunSession {
69
109
  eventCount: r.eventCount,
70
110
  updatedAt: r.updatedAt,
71
111
  subscribers: this.subs.size,
112
+ ...(this.source?.path ? { path: this.source.path } : {}),
72
113
  };
73
114
  }
74
115
  save() {
@@ -95,7 +136,10 @@ export class RunSession {
95
136
  close() {
96
137
  if (this.timer)
97
138
  clearTimeout(this.timer);
139
+ if (this.changeTimer)
140
+ clearTimeout(this.changeTimer);
98
141
  this.source?.stop();
99
142
  this.subs.clear();
143
+ this.changeSubs.clear();
100
144
  }
101
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vale-dinap/lucidly",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "See what your AI agent is doing, at a glance.",
5
5
  "keywords": [
6
6
  "claude",