@vale-dinap/lucidly 0.2.0 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  const MAX_PATCH_LINES = 2000;
2
2
  const zeroUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, responses: 0 });
3
- import { extractPlan, firstLine, hasDeviationCue, hasIntent, intentTitle, isSubstantivePrompt, isReply, endsWithQuestion, issueSummary, matchStep, sentences, similarity, stripMarkdown, tokens, truncate, } from "./text.js";
3
+ import { extractPlan, firstLine, hasDeviationCue, hasIntent, intentTitle, isSubstantivePrompt, isHousekeeping, isReply, endsWithQuestion, lastQuestion, issueSummary, matchStep, sentences, similarity, stripMarkdown, tokens, truncate, } from "./text.js";
4
4
  const MAX_CHUNK = 256 * 1024;
5
5
  const MAX_CONTEXT_ITEMS = 12;
6
6
  /**
@@ -17,6 +17,8 @@ export class Engine {
17
17
  seq = 0;
18
18
  /** The main agent's last words asked the user something. */
19
19
  agentAsked = false;
20
+ /** ...and the question itself. */
21
+ agentQuestion;
20
22
  goalCtx = new Map();
21
23
  units = new Map();
22
24
  calls = new Map();
@@ -159,7 +161,9 @@ export class Engine {
159
161
  row.diff = n.diff;
160
162
  if (n.files?.length && (n.kind === 'edit' || n.kind === 'write'))
161
163
  row.files = n.files;
162
- if (n.kind === 'command' && text) {
164
+ if (n.via)
165
+ row.via = n.via;
166
+ if ((n.kind === 'command' || n.via === 'command') && text) {
163
167
  row.command = text.replace(/^(?:\$|PS>)\s?/, '');
164
168
  if (n.detail)
165
169
  row.short = n.detail.replace(/^\$\s?/, '');
@@ -429,7 +433,10 @@ export class Engine {
429
433
  }
430
434
  // A message sent while the agent is still in its turn is an interjection, not a new task.
431
435
  const midTurn = this.run.status === 'working';
432
- this.run.status = 'working';
436
+ // /compact, /model...: no turn follows them, so the session (and its task) stays as it was.
437
+ const housekeeping = isHousekeeping(text);
438
+ if (!housekeeping)
439
+ this.run.status = 'working';
433
440
  const cur = this.currentGoal();
434
441
  // After the turn, a short message continues the task only if it answers the agent
435
442
  // ("yes", "go ahead", or anything after a question); "all done, pushed" is a new card.
@@ -440,18 +447,84 @@ export class Engine {
440
447
  this.addChunk(g.id, 'prompt', raw ?? text, ts, 'Prompt');
441
448
  return;
442
449
  }
443
- this.setStatus(cur.id, 'active', ts);
450
+ if (!midTurn && !housekeeping)
451
+ return this.startRound(cur, text, ts, raw);
452
+ if (!housekeeping)
453
+ return this.interject(cur, text, ts, raw);
454
+ // A housekeeping command (/compact...) is a note where the task stands, not a message to the agent.
444
455
  const target = cur.unit ?? cur.current ?? cur.id;
445
456
  const n = this.newNode('activity', target, `You: ${truncate(text, 80)}`, { kind: 'user', status: 'done', startedAt: ts });
446
457
  this.addChunk(n.id, 'prompt', raw ?? text, ts, 'User reply');
447
458
  cur.lastTextChunk = null;
448
459
  }
460
+ /**
461
+ * A reply that continues a task after its turn ended starts a new round of it. The reply sits
462
+ * on the task itself, after the steps so far (and before a plan's steps still to come), keeping
463
+ * how the round before it ended; the work that follows goes into a step of its own.
464
+ */
465
+ startRound(g, text, ts, raw) {
466
+ const goal = this.touch(g.id);
467
+ const round = {
468
+ ...(goal.outcome ? { outcome: goal.outcome } : {}),
469
+ ...(g.interrupted ? { interrupted: true } : {}),
470
+ ...(this.agentAsked ? { asked: true } : {}),
471
+ };
472
+ this.closeSubtask(g, ts);
473
+ // The next action picks the next unfinished plan step, or starts a new step (see onToolStart).
474
+ g.current = null;
475
+ g.unit = null;
476
+ g.pending = null;
477
+ g.lastTextChunk = null;
478
+ g.interrupted = false;
479
+ this.userNode(g, text, ts, raw, { round });
480
+ delete goal.outcome;
481
+ delete goal.asked;
482
+ delete goal.question;
483
+ this.setStatus(g.id, 'active', ts);
484
+ }
485
+ /**
486
+ * A message sent while the agent works: like every message of yours, a node of its own on the
487
+ * task, after the steps so far. The work that follows goes into a new step, except that a plan
488
+ * step under way stays the current one until the plan moves on.
489
+ */
490
+ interject(g, text, ts, raw) {
491
+ this.closeSubtask(g, ts);
492
+ const step = g.current ? this.nodes.get(g.current) : undefined;
493
+ if (step && step.origin !== 'plan') {
494
+ if (step.status === 'active')
495
+ this.setStatus(step.id, 'done', ts);
496
+ g.current = null;
497
+ g.unit = null;
498
+ }
499
+ g.pending = null;
500
+ g.lastTextChunk = null;
501
+ this.userNode(g, text, ts, raw, {});
502
+ this.setStatus(g.id, 'active', ts);
503
+ }
504
+ /** A message of yours on the task itself: after the steps so far, before a plan's steps not started. */
505
+ userNode(g, text, ts, raw, extra) {
506
+ const goal = this.touch(g.id);
507
+ const n = this.newNode('activity', g.id, `You: ${truncate(text, 80)}`, { kind: 'user', status: 'done', startedAt: ts, ...extra });
508
+ const rest = goal.children.filter((c) => c !== n.id);
509
+ let at = rest.length;
510
+ while (at > 0) {
511
+ const c = this.nodes.get(rest[at - 1]);
512
+ if (!(c?.type === 'step' && (c.status === 'pending' || c.tentative)))
513
+ break;
514
+ at--;
515
+ }
516
+ rest.splice(at, 0, n.id);
517
+ goal.children = rest;
518
+ this.addChunk(n.id, 'prompt', raw ?? text, ts, 'User reply');
519
+ }
449
520
  // ───────────────────────────── narration
450
521
  onText(text, ts, scope) {
451
522
  if (!text.trim())
452
523
  return;
453
- if (!scope)
524
+ if (!scope) {
454
525
  this.agentAsked = endsWithQuestion(text);
526
+ this.agentQuestion = this.agentAsked ? lastQuestion(text) : undefined;
527
+ }
455
528
  if (scope) {
456
529
  const n = this.scopes.get(scope);
457
530
  if (n) {
@@ -854,6 +927,7 @@ export class Engine {
854
927
  tool: ev.tool,
855
928
  detail: ev.detail,
856
929
  files: ev.files,
930
+ ...(ev.via ? { via: ev.via } : {}),
857
931
  status: 'active',
858
932
  startedAt: ev.ts,
859
933
  });
@@ -862,7 +936,9 @@ export class Engine {
862
936
  if (ev.spawnsAgent)
863
937
  this.scopes.set(ev.callId, node.id);
864
938
  this.calls.set(ev.callId, { node: node.id, unit, tool: ev.tool, label: ev.label });
865
- this.addChunk(node.id, 'tool', ev.raw ?? ev.detail ?? ev.label, ev.ts, ev.label);
939
+ const chunk = this.addChunk(node.id, 'tool', ev.raw ?? ev.detail ?? ev.label, ev.ts, ev.label);
940
+ if (ev.parts?.length && ev.parts.every((p) => p.text.length <= MAX_CHUNK))
941
+ this.chunks[chunk].parts = ev.parts;
866
942
  this.bump(unit, 'actions');
867
943
  this.addContext(unit, ev);
868
944
  const held = ev.spawnsAgent ? this.orphans.get(ev.callId) : undefined;
@@ -1023,6 +1099,17 @@ export class Engine {
1023
1099
  if (g.plan.length === 0 || isLastPlanned || !g.plan.includes(g.current))
1024
1100
  this.setStatus(g.current, 'done', ts);
1025
1101
  }
1102
+ if (this.agentAsked && !interrupted) {
1103
+ goal.asked = true;
1104
+ if (this.agentQuestion)
1105
+ goal.question = this.agentQuestion;
1106
+ else
1107
+ delete goal.question;
1108
+ }
1109
+ else {
1110
+ delete goal.asked;
1111
+ delete goal.question;
1112
+ }
1026
1113
  // The agent finished its turn: the task is done, unless it was cut short. A follow-up
1027
1114
  // reply reopens it (see onPrompt).
1028
1115
  this.setStatus(g.id, interrupted ? 'waiting' : 'done', ts);
@@ -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']);
package/dist/core/text.js CHANGED
@@ -132,6 +132,10 @@ export function extractPlan(text, requireCue = false) {
132
132
  const cue = PLAN_CUE.test(lead);
133
133
  if ((!best.numbered || requireCue) && !cue)
134
134
  return null;
135
+ // Bullets that all open with a bold label ("**Rows:** the spotlight...") describe things, they
136
+ // aren't steps to take.
137
+ if (!best.numbered && best.items.every((s) => /^\*\*[^*]{1,60}?(?::\*\*|\*\*:)/.test(s.trim())))
138
+ return null;
135
139
  const items = best.items.map(cleanItem).filter((s) => s.length > 1);
136
140
  if (items.length < 2)
137
141
  return null;
@@ -200,6 +204,10 @@ const HOUSEKEEPING = /^\/(compact|clear|model|config|cost|context|status|help|me
200
204
  * A short message that answers the agent rather than giving it something new: an approval,
201
205
  * a choice, or anything at all when the agent's last words asked a question.
202
206
  */
207
+ /** A slash command that manages the session (/compact, /model...) rather than asking for anything. */
208
+ export function isHousekeeping(text) {
209
+ return HOUSEKEEPING.test(text.trim());
210
+ }
203
211
  export function isReply(text, agentAsked) {
204
212
  const t = text.trim();
205
213
  // Housekeeping commands never start a task; any other slash command is a new request.
@@ -215,6 +223,11 @@ export function isReply(text, agentAsked) {
215
223
  export function endsWithQuestion(text) {
216
224
  return /\?\s*(\*+|_+|\)|")?\s*$/.test(text.trim());
217
225
  }
226
+ /** The question the agent's words end with: their last sentence, without its formatting. */
227
+ export function lastQuestion(text) {
228
+ const q = sentences(text).at(-1);
229
+ return q && /\?\W*$/.test(q) ? truncate(q.replace(/(?<=\?)\W*$/, ''), 200) : undefined;
230
+ }
218
231
  export function isSubstantivePrompt(text) {
219
232
  const t = text.trim();
220
233
  const words = t.split(/\s+/).filter(Boolean).length;
@@ -8,7 +8,7 @@ import { readFile, readdir, stat } from 'node:fs/promises';
8
8
  import { extname, normalize, resolve, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { RunSession } from "./session.js";
11
- import { ClaudeSessionSource, findSession, listSessions } from "../sources/claude-sessions.js";
11
+ import { ClaudeSessionSource, LIVE_MS, findSession, listSessions } from "../sources/claude-sessions.js";
12
12
  const WEB_DIR = fileURLToPath(new URL('../../web/', import.meta.url));
13
13
  const SRC_DIR = fileURLToPath(new URL('../', import.meta.url));
14
14
  const STARTED = Date.now();
@@ -33,7 +33,6 @@ async function codeChangedSince(t) {
33
33
  return false;
34
34
  }
35
35
  const MAX_BODY = 512 * 1024 * 1024;
36
- const LIVE_MS = 3 * 60 * 1000;
37
36
  const TYPES = {
38
37
  '.html': 'text/html; charset=utf-8',
39
38
  '.js': 'text/javascript; charset=utf-8',
@@ -43,11 +42,19 @@ const TYPES = {
43
42
  '.png': 'image/png',
44
43
  '.ico': 'image/x-icon',
45
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;
46
49
  export class LucidlyServer {
47
50
  runs = new Map();
48
51
  byKey = new Map();
49
52
  seq = 0;
50
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;
51
58
  port = 0;
52
59
  cwd;
53
60
  wantPort;
@@ -69,6 +76,8 @@ export class LucidlyServer {
69
76
  s.off('error', fail);
70
77
  this.server = s;
71
78
  this.port = s.address().port;
79
+ this.ticker = setInterval(() => this.tick(), TICK_MS);
80
+ this.ticker.unref();
72
81
  ok();
73
82
  });
74
83
  });
@@ -82,6 +91,8 @@ export class LucidlyServer {
82
91
  throw new Error(`No free port from ${this.wantPort}`);
83
92
  }
84
93
  async close() {
94
+ if (this.ticker)
95
+ clearInterval(this.ticker);
85
96
  for (const r of this.runs.values())
86
97
  r.close();
87
98
  this.server?.closeAllConnections?.();
@@ -111,8 +122,66 @@ export class LucidlyServer {
111
122
  r.close();
112
123
  this.runs.delete(id);
113
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);
114
132
  return true;
115
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
+ }
116
185
  // ───────────────────────────── HTTP
117
186
  hostOk(req) {
118
187
  const host = (req.headers.host ?? '').toLowerCase();
@@ -152,10 +221,13 @@ export class LucidlyServer {
152
221
  if (!run)
153
222
  return send(res, 404, { error: 'no such run' });
154
223
  const [, , action, arg] = m;
155
- if (!action && req.method === 'DELETE')
156
- 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
+ }
157
229
  if (action === 'stream')
158
- return this.stream(req, res, run);
230
+ return this.stream(req, res, run, url.searchParams.get('client') ?? undefined);
159
231
  if (action === 'raw' && arg) {
160
232
  if (!run.engine.nodes.has(arg))
161
233
  return send(res, 404, { error: 'no such node' });
@@ -183,6 +255,14 @@ export class LucidlyServer {
183
255
  if (path === '/api/runs' && req.method === 'GET') {
184
256
  return send(res, 200, { runs: [...this.runs.values()].map((r) => r.info()) });
185
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
+ }
186
266
  if (path === '/api/sessions' && req.method === 'GET') {
187
267
  const all = url.searchParams.get('scope') === 'all';
188
268
  const sessions = await listSessions({ cwd: all ? undefined : this.cwd, limit: all ? 60 : 30 });
@@ -223,21 +303,44 @@ export class LucidlyServer {
223
303
  }
224
304
  return send(res, 404, { error: 'not found' });
225
305
  }
226
- stream(req, res, run) {
227
- res.writeHead(200, {
228
- 'content-type': 'text/event-stream; charset=utf-8',
229
- 'cache-control': 'no-store',
230
- connection: 'keep-alive',
231
- 'x-accel-buffering': 'no',
232
- });
233
- 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);
234
312
  const unsubscribe = run.subscribe((event, data) => {
235
313
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
236
- });
314
+ }, client);
237
315
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);
238
316
  req.on('close', () => {
239
317
  clearInterval(ping);
240
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
+ }
241
344
  });
242
345
  }
243
346
  async static(res, path) {
@@ -261,6 +364,15 @@ export class LucidlyServer {
261
364
  res.end(body);
262
365
  }
263
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
+ }
264
376
  function send(res, status, body) {
265
377
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
266
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
  }
@@ -15,6 +15,8 @@ export function projectsDir() {
15
15
  export function encodeCwd(cwd) {
16
16
  return cwd.replace(/[^a-zA-Z0-9]/g, '-');
17
17
  }
18
+ /** A session written to within this long counts as live (the agent is, or was just, at work). */
19
+ export const LIVE_MS = 3 * 60 * 1000;
18
20
  async function readSlice(path, start, length) {
19
21
  const fh = await open(path, 'r');
20
22
  try {
@@ -26,9 +28,20 @@ async function readSlice(path, start, length) {
26
28
  await fh.close();
27
29
  }
28
30
  }
31
+ // Descriptions by path, reused while the file is unchanged: the session list refreshes every few
32
+ // seconds while it is open, and only the sessions written to since are read again.
33
+ const described = new Map();
29
34
  /** Cheap metadata: read the head for cwd/first prompt and the tail for the latest title. */
30
35
  export async function describeSession(path) {
31
36
  const st = await stat(path);
37
+ const known = described.get(path);
38
+ if (known && known.mtime === st.mtimeMs && known.size === st.size)
39
+ return known;
40
+ const info = await readDescription(path, st);
41
+ described.set(path, info);
42
+ return info;
43
+ }
44
+ async function readDescription(path, st) {
32
45
  const info = {
33
46
  id: basename(path, '.jsonl'),
34
47
  path,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vale-dinap/lucidly",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "See what your AI agent is doing, at a glance.",
5
5
  "keywords": [
6
6
  "claude",