@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.
- package/CHANGELOG.md +74 -26
- package/README.md +60 -13
- package/dist/adapters/claude-code.js +38 -5
- package/dist/adapters/claude-tools.js +73 -0
- package/dist/adapters/shell-writes.js +506 -0
- package/dist/cli.js +19 -8
- package/dist/core/engine.js +93 -6
- package/dist/core/summary.js +41 -0
- package/dist/core/text.js +13 -0
- package/dist/server/server.js +126 -14
- package/dist/server/session.js +49 -5
- package/dist/sources/claude-sessions.js +13 -0
- package/package.json +1 -1
- package/web/app.js +4884 -3411
- package/web/index.html +14 -4
- package/web/style.css +1282 -973
- package/web/theme.js +9 -0
package/dist/core/engine.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/server/server.js
CHANGED
|
@@ -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
|
|
156
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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));
|
package/dist/server/session.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
70
|
+
this.subs.set(send, client);
|
|
71
|
+
this.lastWatched = Date.now();
|
|
57
72
|
send('snapshot', this.engine.snapshot());
|
|
58
|
-
return () =>
|
|
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,
|