@vale-dinap/lucidly 0.1.0 → 0.3.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.
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Which files a shell command changes, read from the command text alone.
3
+ *
4
+ * Agents often edit files through the shell instead of their edit tools: `sed -i`, a redirect, a
5
+ * heredoc, a small node or python script. The transcript only records the command, so this
6
+ * recognizes the common ways of writing a file and returns their targets. It errs on the side of
7
+ * missing a change rather than inventing one:
8
+ * - shell forms (redirects, tee, sed -i, cp, mv, rm, touch, PowerShell's Set-Content and the like)
9
+ * count when they are commands, never inside quotes, comments or heredoc text;
10
+ * - a script (node -e, python - <<EOF, ...) counts only if it calls something that writes files;
11
+ * then the path given straight to that call counts, and other path-like strings in it only
12
+ * when the caller recognizes them as files of the session (`known`).
13
+ */
14
+ const SEP = new Set(['&&', '||', ';', '|', '&', '\n', '(', ')', '{', '}']);
15
+ // Redirects (with descriptor duplications like 2>&1 kept whole) and command separators.
16
+ const SH_OPS = /^(?:\d?>>?&\d|&>>?|\d?>>?\|?|<<<|<|&&|\|\||;|\||&|\(|\))/;
17
+ const PS_OPS = /^(?:\*>>?|\d>>?(?:&\d)?|>>?|&&|\|\||;|\||\(|\)|\{|\})/;
18
+ /** Operators followed by a file name: input or output redirects. */
19
+ const TAKES_TARGET = /^(?:\d|&|\*)?(?:>>?\|?|<)$/;
20
+ const NULL_TARGETS = /^(?:\/dev\/(?:null|stdout|stderr|tty)|nul|\$null|&\d?|-)$/i;
21
+ /** Splits a command line into simple commands, words and operators, like a (small) shell would. */
22
+ function parse(src, powershell) {
23
+ const out = [];
24
+ let cur = { words: [], bodies: [], spans: [] };
25
+ let word = '';
26
+ let inWord = false;
27
+ let wordStart = 0;
28
+ let i = 0;
29
+ const heredocs = [];
30
+ const mark = () => {
31
+ if (!inWord)
32
+ wordStart = i;
33
+ inWord = true;
34
+ };
35
+ const endWord = () => {
36
+ if (inWord)
37
+ cur.words.push({ t: word, at: [wordStart, i] });
38
+ word = '';
39
+ inWord = false;
40
+ };
41
+ const endCmd = () => {
42
+ endWord();
43
+ if (cur.words.length || cur.bodies.length)
44
+ out.push(cur);
45
+ cur = { words: [], bodies: [], spans: [] };
46
+ };
47
+ while (i < src.length) {
48
+ const c = src[i];
49
+ const at = (s) => src.startsWith(s, i);
50
+ if (!powershell && c === '\\' && i + 1 < src.length) {
51
+ if (src[i + 1] !== '\n') {
52
+ word += src[i + 1];
53
+ mark();
54
+ }
55
+ i += 2;
56
+ continue;
57
+ }
58
+ if (powershell && c === '`' && i + 1 < src.length) {
59
+ if (src[i + 1] !== '\n') {
60
+ word += src[i + 1];
61
+ mark();
62
+ }
63
+ i += 2;
64
+ continue;
65
+ }
66
+ if (c === "'" || c === '"') {
67
+ // PowerShell here-strings: @' ... '@ and @" ... "@ hold text, not commands.
68
+ if (powershell && word === '@' && src[i + 1] === '\n') {
69
+ const end = src.indexOf(`\n${c}@`, i);
70
+ const stop = end < 0 ? src.length : end;
71
+ cur.bodies.push(src.slice(i + 2, stop));
72
+ word = '';
73
+ inWord = false;
74
+ cur.words.push({ t: '<here-string>' });
75
+ i = stop + 3;
76
+ continue;
77
+ }
78
+ let j = i + 1;
79
+ let s = '';
80
+ while (j < src.length && src[j] !== c) {
81
+ if (c === '"' && !powershell && src[j] === '\\' && j + 1 < src.length) {
82
+ s += src[j + 1];
83
+ j += 2;
84
+ continue;
85
+ }
86
+ if (powershell && src[j] === '`' && j + 1 < src.length) {
87
+ s += src[j + 1];
88
+ j += 2;
89
+ continue;
90
+ }
91
+ s += src[j++];
92
+ }
93
+ word += s;
94
+ mark();
95
+ i = j + 1;
96
+ continue;
97
+ }
98
+ if (c === '#' && !inWord) {
99
+ while (i < src.length && src[i] !== '\n')
100
+ i++;
101
+ continue;
102
+ }
103
+ if (c === '\n') {
104
+ endCmd();
105
+ i++;
106
+ // Heredoc bodies start on the next line and run to their delimiter line.
107
+ for (const h of heredocs.splice(0)) {
108
+ const start = i;
109
+ const lines = [];
110
+ while (i < src.length) {
111
+ const nl = src.indexOf('\n', i);
112
+ const line = src.slice(i, nl < 0 ? src.length : nl);
113
+ i = nl < 0 ? src.length : nl + 1;
114
+ if ((h.strip ? line.replace(/^\t+/, '') : line) === h.delim)
115
+ break;
116
+ lines.push(line);
117
+ }
118
+ h.into.bodies.push(lines.join('\n'));
119
+ h.into.spans.push([start, i]);
120
+ }
121
+ continue;
122
+ }
123
+ if (c === ' ' || c === '\t' || c === '\r') {
124
+ endWord();
125
+ i++;
126
+ continue;
127
+ }
128
+ // $( ... ) and backticks: keep them inside the word, unparsed.
129
+ if (!powershell && (at('$(') || c === '`')) {
130
+ const close = c === '`' ? '`' : ')';
131
+ let depth = 0;
132
+ let j = i + (c === '`' ? 1 : 2);
133
+ for (; j < src.length; j++) {
134
+ if (close === ')' && src[j] === '(')
135
+ depth++;
136
+ else if (src[j] === close && depth-- === 0)
137
+ break;
138
+ }
139
+ word += src.slice(i, j + 1);
140
+ mark();
141
+ i = j + 1;
142
+ continue;
143
+ }
144
+ if (!powershell && at('<<') && !at('<<<')) {
145
+ endWord();
146
+ const m = /^<<(-?)\s*(['"]?)([\w.-]+)\2/.exec(src.slice(i));
147
+ if (m) {
148
+ heredocs.push({ delim: m[3], strip: m[1] === '-', into: cur });
149
+ cur.words.push({ t: '<<', op: true });
150
+ i += m[0].length;
151
+ continue;
152
+ }
153
+ }
154
+ const op = (powershell ? PS_OPS : SH_OPS).exec(src.slice(i, i + 8))?.[0];
155
+ // A digit before > is a file descriptor only at the start of a word ("2>x"), not in "a2>x".
156
+ if (op && !(inWord && /^\d/.test(op))) {
157
+ endWord();
158
+ if (op === '&' && !powershell)
159
+ cur.background = true;
160
+ if (SEP.has(op))
161
+ endCmd();
162
+ else
163
+ cur.words.push({ t: op, op: true });
164
+ i += op.length;
165
+ continue;
166
+ }
167
+ word += c;
168
+ mark();
169
+ i++;
170
+ }
171
+ endCmd();
172
+ return out;
173
+ }
174
+ const WRITE_REDIRECTS = new Set(['>', '>>', '>|', '&>', '&>>', '1>', '1>>', '*>', '*>>']);
175
+ const INTERPRETERS = /^(?:node|nodejs|deno|bun|python[\d.]*|py|ruby|php|perl)(?:\.exe)?$/i;
176
+ const WRAPPERS = new Set(['&', '.', 'sudo', 'env', 'time', 'command', 'nohup', 'exec', 'nice', 'stdbuf', 'npx', 'call']);
177
+ const base = (w) => w.replace(/^.*[\\/]/, '').toLowerCase();
178
+ const isOpt = (w) => /^-/.test(w) && w !== '-';
179
+ /** Files changed by one command line (Bash or PowerShell). */
180
+ export function shellWrites(command, opts = {}) {
181
+ const out = [];
182
+ const add = (path, op, dir, guessed) => {
183
+ path = path.trim();
184
+ // Not a file: /dev/null and the like, globs, variables and substitutions, URLs.
185
+ if (!path || NULL_TARGETS.test(path) || /[*?\n]|\$[\w({]|^\w{2,}:\/\//.test(path))
186
+ return;
187
+ if (!out.some((w) => w.path === path && w.dir === dir))
188
+ out.push({ path, op, ...(dir ? { dir } : {}), ...(guessed ? { guessed } : {}) });
189
+ };
190
+ let dir;
191
+ // Variables set earlier in the same command line (SP=/tmp/x; ... > $SP/out.txt).
192
+ const vars = new Map();
193
+ const expand = (w) => w.replace(/\$(?:\{(\w+)\}|(\w+))/g, (m, a, b) => vars.get((a ?? b).toLowerCase()) ?? m);
194
+ for (const cmd of parse(command, !!opts.powershell)) {
195
+ const words = [];
196
+ for (let k = 0; k < cmd.words.length; k++) {
197
+ const w = cmd.words[k];
198
+ if (w.op) {
199
+ const target = cmd.words[k + 1];
200
+ if (TAKES_TARGET.test(w.t) && target && !target.op) {
201
+ // Output sent to a log (a .log file, or a command left running in the background) is not an edit.
202
+ const t = expand(target.t);
203
+ if (WRITE_REDIRECTS.has(w.t) && !cmd.background && !/\.log$/i.test(t))
204
+ add(t, 'write', dir);
205
+ k++; // the redirect's file is not an argument of the command
206
+ }
207
+ continue;
208
+ }
209
+ words.push(expand(w.t));
210
+ }
211
+ // NAME=value (Bash, also after export) and $name = 'value' (PowerShell) set a variable.
212
+ const assign = words[0] === 'export' ? words.slice(1) : words;
213
+ if (assign.length && assign.every((w) => /^[A-Za-z_]\w*=/.test(w))) {
214
+ for (const w of assign)
215
+ vars.set(w.slice(0, w.indexOf('=')).toLowerCase(), w.slice(w.indexOf('=') + 1));
216
+ continue;
217
+ }
218
+ if (opts.powershell && words.length === 3 && words[1] === '=' && /^\$\w+$/.test(words[0])) {
219
+ vars.set(words[0].slice(1).toLowerCase(), words[2]);
220
+ continue;
221
+ }
222
+ // Leading VAR=value assignments and wrappers like sudo.
223
+ while (words.length && (/^[A-Za-z_]\w*=/.test(words[0]) || WRAPPERS.has(base(words[0]))))
224
+ words.shift();
225
+ if (!words.length)
226
+ continue;
227
+ const name = base(words[0]);
228
+ const args = words.slice(1);
229
+ const plain = args.filter((a) => !isOpt(a));
230
+ if (opts.powershell) {
231
+ powershellCommand(name, args, (p, op) => add(p, op, dir));
232
+ }
233
+ if (name === 'cd' || name === 'pushd' || name === 'set-location' || name === 'sl') {
234
+ if (plain[0])
235
+ dir = dir && !/^(?:[a-z]:)?[\\/~]/i.test(plain[0]) ? `${dir}/${plain[0]}` : plain[0];
236
+ continue;
237
+ }
238
+ if (name === 'tee')
239
+ for (const a of plain.filter((p) => !/\.log$/i.test(p)))
240
+ add(a, 'write', dir);
241
+ else if (name === 'touch')
242
+ for (const a of plain)
243
+ add(a, 'write', dir);
244
+ else if (name === 'rm' || name === 'unlink' || name === 'del')
245
+ for (const a of plain)
246
+ add(a, 'delete', dir);
247
+ else if (name === 'cp') {
248
+ const t = args.indexOf('-t');
249
+ const dest = t >= 0 ? args[t + 1] : plain.length > 1 ? plain[plain.length - 1] : undefined;
250
+ if (dest)
251
+ add(dest, 'write', dir);
252
+ }
253
+ else if (name === 'mv') {
254
+ if (plain.length > 1) {
255
+ for (const a of plain.slice(0, -1))
256
+ add(a, 'from', dir);
257
+ add(plain[plain.length - 1], 'to', dir);
258
+ }
259
+ }
260
+ else if (name === 'sed' && args.some((a) => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place'))) {
261
+ inPlace(args, (p) => add(p, 'write', dir));
262
+ }
263
+ else if (name === 'perl' && args.some((a) => /^-[a-zA-Z]*i/.test(a))) {
264
+ inPlace(args, (p) => add(p, 'write', dir));
265
+ }
266
+ else if ((name === 'bash' || name === 'sh' || name === 'zsh') && args[0] === '-c' && args[1]) {
267
+ for (const w of shellWrites(args[1]))
268
+ add(w.path, w.op, w.dir ?? dir);
269
+ }
270
+ else if ((name === 'powershell' || name === 'pwsh') && args.length) {
271
+ const c = args.findIndex((a) => /^-c(?:ommand)?$/i.test(a));
272
+ const code = c >= 0 ? args.slice(c + 1).join(' ') : '';
273
+ if (code)
274
+ for (const w of shellWrites(code, { powershell: true }))
275
+ add(w.path, w.op, w.dir ?? dir);
276
+ }
277
+ else if (INTERPRETERS.test(name)) {
278
+ const e = args.findIndex((a) => /^(?:-e|-c|-p|-pe|--eval|--print|-r)$/.test(a));
279
+ const code = e >= 0 && args[e + 1] ? [args[e + 1]] : args.includes('-') || !plain.length ? cmd.bodies : [];
280
+ for (const src of code)
281
+ for (const w of scriptWrites(src))
282
+ add(w.path, w.op, dir, w.guessed);
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+ /** sed -i / perl -i: every argument after the script is a file. */
288
+ function inPlace(args, add) {
289
+ let script = false;
290
+ let skipNext = false;
291
+ const files = [];
292
+ for (let k = 0; k < args.length; k++) {
293
+ const a = args[k];
294
+ if (skipNext) {
295
+ skipNext = false;
296
+ continue;
297
+ }
298
+ if (/^(?:-e|-f|--expression|--file)$/.test(a)) {
299
+ script = true;
300
+ skipNext = true;
301
+ continue;
302
+ }
303
+ if (/^-[a-zA-Z]*e$/.test(a) && a !== '-e') {
304
+ // perl -pe 's/..//' and sed -ie: the script follows
305
+ script = true;
306
+ skipNext = true;
307
+ continue;
308
+ }
309
+ if (isOpt(a))
310
+ continue;
311
+ files.push(a);
312
+ }
313
+ for (const f of script ? files : files.slice(1))
314
+ add(f);
315
+ }
316
+ /** PowerShell's file cmdlets (and their usual aliases). */
317
+ function powershellCommand(name, args, add) {
318
+ const named = (...keys) => {
319
+ const k = args.findIndex((a) => keys.includes(a.toLowerCase()));
320
+ return k >= 0 ? args[k + 1] : undefined;
321
+ };
322
+ // Positional arguments: skip -Name value pairs (switches like -Force take no value, but the
323
+ // ones that matter here are recognized by name first).
324
+ const positional = [];
325
+ for (let k = 0; k < args.length; k++) {
326
+ if (/^-[a-z]/i.test(args[k])) {
327
+ if (!/^-(?:force|recurse|nonewline|append|confirm(?::\S+)?|whatif|passthru|r|fo?)$/i.test(args[k]))
328
+ k++;
329
+ continue;
330
+ }
331
+ positional.push(args[k]);
332
+ }
333
+ const path = () => named('-path', '-literalpath', '-filepath', '-lp') ?? positional[0];
334
+ switch (name) {
335
+ case 'set-content':
336
+ case 'sc':
337
+ case 'add-content':
338
+ case 'ac':
339
+ case 'out-file':
340
+ case 'new-item':
341
+ case 'ni': {
342
+ const p = path();
343
+ if (p && !(name.startsWith('n') && /^dir/i.test(named('-itemtype', '-type') ?? '')))
344
+ add(p, 'write');
345
+ break;
346
+ }
347
+ case 'remove-item':
348
+ case 'ri':
349
+ case 'erase':
350
+ case 'rd':
351
+ case 'rmdir': {
352
+ const p = path();
353
+ if (p)
354
+ add(p, 'delete');
355
+ break;
356
+ }
357
+ case 'copy-item':
358
+ case 'cpi':
359
+ case 'copy': {
360
+ const d = named('-destination') ?? positional[1];
361
+ if (d)
362
+ add(d, 'write');
363
+ break;
364
+ }
365
+ case 'move-item':
366
+ case 'mi':
367
+ case 'move':
368
+ case 'rename-item':
369
+ case 'rni':
370
+ case 'ren': {
371
+ const p = path();
372
+ const d = named('-destination', '-newname') ?? positional[1];
373
+ if (p && d) {
374
+ add(p, 'from');
375
+ add(name.startsWith('ren') || name === 'rni' ? p.replace(/[^\\/]*$/, d) : d, 'to');
376
+ }
377
+ break;
378
+ }
379
+ }
380
+ }
381
+ // ───────────────────────────── scripts (node, python, ...)
382
+ /** Calls that write, delete or move files, with the target as their first argument. */
383
+ const WRITE_CALL = /\b(?:(?:fs|fsp|promises)\s*\.\s*)?(?:writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream|copyFileSync|copyFile|renameSync|unlinkSync|rmSync|cpSync|WriteAllText|WriteAllLines|WriteAllBytes|AppendAllText)\s*\(|\bopen\s*\([^()\n]*,\s*(?:mode\s*=\s*)?['"][wax]b?\+?['"]|\.write_(?:text|bytes)\s*\(|\bshutil\s*\.\s*(?:copy\w*|move)\s*\(|\bos\s*\.\s*(?:remove|unlink|rename|replace)\s*\(|\bFile\s*\.\s*(?:write|open)\s*\(/;
384
+ const TARGETED = /\b(?:writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream|WriteAllText|WriteAllLines|WriteAllBytes|AppendAllText|open|File\s*\.\s*write)\s*\(\s*(['"`])([^'"`\n$]+)\1|\bPath\s*\(\s*(['"])([^'"\n]+)\3\s*\)\s*\.\s*write_(?:text|bytes)|\b(?:unlinkSync|rmSync|os\s*\.\s*(?:remove|unlink))\s*\(\s*(['"`])([^'"`\n$]+)\5/g;
385
+ /** A string that looks like a file path: a name with an extension, maybe in folders. */
386
+ const PATHISH = /(['"`])((?:[\w@.~-]+[\\/]+)*[\w-][\w.-]*\.[A-Za-z][A-Za-z0-9]{0,9}|(?:[A-Za-z]:)?(?:[\\/]+[\w@.~ -]+)+\.[A-Za-z][A-Za-z0-9]{0,9})\1/g;
387
+ /** Strings passed to calls that only read or import. */
388
+ const READ_ONLY = /(?:readFileSync|readFile|existsSync|statSync|require|import|from|open|read_text|read_bytes|Path|resolve|join|dirname|basename)\s*\(?\s*$/;
389
+ export function scriptWrites(code) {
390
+ if (!WRITE_CALL.test(code))
391
+ return [];
392
+ const out = [];
393
+ const sure = new Set();
394
+ for (const m of code.matchAll(TARGETED)) {
395
+ const p = m[2] ?? m[4] ?? m[6];
396
+ const del = m[6] !== undefined;
397
+ // open('x') without a write mode is a read: TARGETED matched any open(), so check the mode.
398
+ if (/^open/.test(m[0]) && !new RegExp(`open\\s*\\(\\s*(['"\`])${escapeRe(p)}\\1[^()\\n]*,\\s*(?:mode\\s*=\\s*)?['"][wax]`).test(code))
399
+ continue;
400
+ sure.add(p);
401
+ out.push({ path: p, op: del ? 'delete' : 'write' });
402
+ }
403
+ for (const m of code.matchAll(PATHISH)) {
404
+ const p = m[2];
405
+ if (sure.has(p) || /^(?:node|npm|https?):/.test(p))
406
+ continue;
407
+ const before = code.slice(Math.max(0, m.index - 40), m.index);
408
+ // Only a value given to something (edit('a.ts', ...), ['a.ts', 'b.ts'], f = 'a.ts'), not a
409
+ // path quoted in text the script writes, and not one handed to a call that only reads.
410
+ if (!/[([,=]\s*$/.test(before) || READ_ONLY.test(before))
411
+ continue;
412
+ if (out.some((w) => w.path === p))
413
+ continue;
414
+ out.push({ path: p, op: 'write', guessed: true });
415
+ }
416
+ return out;
417
+ }
418
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
419
+ // ───────────────────────────── a command, split into readable parts
420
+ // A long command cut where it embeds other text: the shell lines, a script it runs (node - <<EOF,
421
+ // python -c "..."), content it writes to a file (cat > a.py <<EOF), or other text fed to a command.
422
+ /** Interpreters and the language of the scripts they run ('' keeps the interpreter's name). */
423
+ const LANG = [
424
+ [/^(?:node|nodejs|deno|bun)$/, 'javascript'],
425
+ [/^(?:python[\d.]*|py)$/, 'python'],
426
+ [/^(?:bash|sh|zsh)$/, 'shell'],
427
+ [/^(?:ruby|php|perl)$/, ''],
428
+ ];
429
+ const langOf = (name) => {
430
+ const n = name.replace(/\.exe$/, '');
431
+ for (const [re, lang] of LANG)
432
+ if (re.test(n))
433
+ return lang || n;
434
+ return undefined;
435
+ };
436
+ /**
437
+ * The parts of a Bash command, in order, or undefined when it embeds nothing worth splitting out
438
+ * (then it is shown whole, as before). Inline scripts are cut out of their line, which keeps an
439
+ * ellipsis in their place; heredoc bodies follow the line that starts them.
440
+ */
441
+ export function commandParts(command) {
442
+ const cuts = [];
443
+ for (const cmd of parse(command, false)) {
444
+ const words = cmd.words.filter((w) => !w.op);
445
+ let k = 0;
446
+ while (k < words.length && (/^[A-Za-z_]\w*=/.test(words[k].t) || WRAPPERS.has(base(words[k].t))))
447
+ k++;
448
+ const name = words[k] ? base(words[k].t) : '';
449
+ const lang = langOf(name);
450
+ // Where a heredoc goes: a file it is written to (> file, tee file), else the command reading it.
451
+ let file;
452
+ for (let j = 0; j < cmd.words.length - 1; j++) {
453
+ const w = cmd.words[j];
454
+ if (w.op && WRITE_REDIRECTS.has(w.t) && !cmd.words[j + 1].op)
455
+ file = cmd.words[j + 1].t;
456
+ }
457
+ if (!file && name === 'tee')
458
+ file = words.slice(k + 1).find((w) => !isOpt(w.t))?.t;
459
+ cmd.bodies.forEach((text, b) => {
460
+ const span = cmd.spans[b];
461
+ if (!span)
462
+ return;
463
+ const part = file ? { kind: 'content', file, text } : lang !== undefined ? { kind: 'script', lang, text } : { kind: 'text', text };
464
+ cuts.push({ start: span[0], end: span[1], part, inline: false });
465
+ });
466
+ // node -e "...", python -c "...", bash -c "...": cut out when long enough to deserve a block.
467
+ if (lang !== undefined) {
468
+ const e = words.findIndex((w, j) => j > k && /^(?:-e|-c|-p|--eval|--print)$/.test(w.t));
469
+ const script = e >= 0 ? words[e + 1] : undefined;
470
+ if (script?.at && (script.t.includes('\n') || script.t.length > 100)) {
471
+ cuts.push({ start: script.at[0], end: script.at[1], part: { kind: 'script', lang, text: script.t }, inline: true });
472
+ }
473
+ }
474
+ }
475
+ if (!cuts.length)
476
+ return undefined;
477
+ cuts.sort((a, b) => a.start - b.start);
478
+ const parts = [];
479
+ const held = [];
480
+ let shell = '';
481
+ let pos = 0;
482
+ const flush = () => {
483
+ const text = shell.replace(/\s+$/, '');
484
+ if (text.trim())
485
+ parts.push({ kind: 'shell', text });
486
+ shell = '';
487
+ parts.push(...held.splice(0));
488
+ };
489
+ for (const c of cuts) {
490
+ if (c.start < pos)
491
+ continue; // nested in an earlier cut
492
+ shell += command.slice(pos, c.start);
493
+ if (c.inline) {
494
+ shell += '…';
495
+ held.push(c.part);
496
+ }
497
+ else {
498
+ flush();
499
+ parts.push(c.part);
500
+ }
501
+ pos = c.end;
502
+ }
503
+ shell += command.slice(pos);
504
+ flush();
505
+ return parts;
506
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lucidly start the viewer; opens the latest session of this project
3
+ * lucidly start the viewer; opens this project's session if there is an obvious one
4
4
  * lucidly <session-id|file> open a specific Claude Code session (id prefix or .jsonl path)
5
5
  * agent | lucidly view a piped stream (Claude stream-json or plain text)
6
6
  * lucidly --load run.lucidly.json
@@ -16,7 +16,7 @@ import { Engine } from "./core/engine.js";
16
16
  import { actionList, outline } from "./outline.js";
17
17
  import { LucidlyServer } from "./server/server.js";
18
18
  import { RunSession } from "./server/session.js";
19
- import { ClaudeSessionSource, findSession, listSessions } from "./sources/claude-sessions.js";
19
+ import { ClaudeSessionSource, LIVE_MS, findSession, listSessions } from "./sources/claude-sessions.js";
20
20
  import { StreamSource } from "./sources/stdin.js";
21
21
  function parseArgs(argv) {
22
22
  const a = {
@@ -84,7 +84,8 @@ function parseArgs(argv) {
84
84
  const HELP = `Lucidly — see what your AI agent is doing, at a glance.
85
85
 
86
86
  Usage:
87
- lucidly [session] Start the viewer (default: latest session of this folder)
87
+ lucidly [session] Start the viewer. Without a session, it opens this folder's only
88
+ session, or the only live one; otherwise it lets you choose
88
89
  <agent> | lucidly View a piped stream (Claude stream-json or plain text)
89
90
  lucidly --load <file> Open a saved .lucidly.json run
90
91
  lucidly list [--all] List recent Claude Code sessions
@@ -191,14 +192,13 @@ async function main() {
191
192
  first = await server.open(new ClaudeSessionSource(await resolveSession(args.target)));
192
193
  }
193
194
  else if (!first) {
194
- const [latest] = await listSessions({ cwd: args.cwd, limit: 1 });
195
- if (latest)
196
- first = await server.open(new ClaudeSessionSource(latest.path));
195
+ const pick = await obviousSession(args.cwd);
196
+ if (pick)
197
+ first = await server.open(new ClaudeSessionSource(pick));
197
198
  }
198
199
  const url = server.url + (first ? `#run=${first.id}` : '');
199
200
  console.error(`Lucidly is running at ${url}`);
200
- if (first)
201
- console.error(`Showing: ${first.engine.run.title ?? first.label}`);
201
+ console.error(first ? `Showing: ${first.engine.run.title ?? first.label}` : 'Choose a session in the browser.');
202
202
  console.error('Press Ctrl+C to stop.');
203
203
  if (args.open)
204
204
  openBrowser(url);
@@ -209,6 +209,17 @@ async function main() {
209
209
  process.on('SIGINT', stop);
210
210
  process.on('SIGTERM', stop);
211
211
  }
212
+ /**
213
+ * The session to open at startup, when there is no doubt which one is wanted: the folder's only
214
+ * session, or the only one being written to (live). Otherwise none, and the page lets you choose.
215
+ */
216
+ async function obviousSession(cwd) {
217
+ const sessions = await listSessions({ cwd });
218
+ if (sessions.length === 1)
219
+ return sessions[0].path;
220
+ const live = sessions.filter((s) => Date.now() - s.mtime < LIVE_MS);
221
+ return live.length === 1 ? live[0].path : undefined;
222
+ }
212
223
  main().catch((e) => {
213
224
  console.error(`lucidly: ${e?.message ?? e}`);
214
225
  process.exit(1);