@lumoai/cli 1.18.0 → 1.19.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.
@@ -36,6 +36,16 @@ do `generate + tsc` atomically once at the end.
36
36
  in first) — `cli/` has no jest config, and running from the main checkout hits
37
37
  the `cli/package.json` haste collision and silently runs the wrong tests.
38
38
 
39
+ **Never run `npm install` / `npm ci` inside a worktree.** The worktree shares
40
+ the main checkout's `node_modules` via a symlink; npm does not respect the link
41
+ — it deletes it and reifies a full standalone `node_modules` (thousands of
42
+ packages, ~1 min, and the shared prisma-client is gone). Run installs only in
43
+ the main checkout, then re-create the symlink if npm replaced it. (Older npm
44
+ could instead plant a self-referential `node_modules/node_modules` in the shared
45
+ tree, which hard-panics Turbopack's `next build`; the `prebuild`/`predev`/
46
+ `preanalyze` guard — `scripts/fix-nodemodules-selflink.ts` — removes that link
47
+ automatically, but the rule above avoids the whole mess.)
48
+
39
49
  ## `lumo worktree rm <LUM-N>`
40
50
 
41
51
  Removes the worktree for a task. Requires `--yes`. Refuses a dirty worktree
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatHookStdoutLines = formatHookStdoutLines;
4
4
  exports.formatAutoBindLine = formatAutoBindLine;
5
5
  exports.resolveSessionStartStdout = resolveSessionStartStdout;
6
+ exports.augmentBodyWithUsage = augmentBodyWithUsage;
6
7
  exports.runHook = runHook;
7
8
  exports.runHookWithBody = runHookWithBody;
8
9
  const config_1 = require("./config");
@@ -12,6 +13,7 @@ const sanitize_1 = require("./sanitize");
12
13
  const agent_1 = require("./agent");
13
14
  const git_task_1 = require("./git-task");
14
15
  const format_1 = require("./format");
16
+ const transcript_usage_1 = require("./transcript-usage");
15
17
  /**
16
18
  * Hard timeout for the hook POST. On timeout the request is aborted,
17
19
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -260,6 +262,31 @@ async function postBindTask(sessionId, identifier, token, apiUrl) {
260
262
  clearTimeout(timer);
261
263
  }
262
264
  }
265
+ /**
266
+ * For stop/stop-failure hooks, read the transcript named in the payload and
267
+ * fold cumulative token totals into the body under `_lumo_usage` so the server
268
+ * can persist them on the Session. Best-effort: returns the body unchanged on
269
+ * any failure (no transcript_path, unreadable, unparseable, no usage). The
270
+ * hook is never blocked or failed by this.
271
+ */
272
+ function augmentBodyWithUsage(path, body) {
273
+ if (path !== 'stop' && path !== 'stop-failure')
274
+ return body;
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(body || '{}');
278
+ }
279
+ catch {
280
+ return body;
281
+ }
282
+ const transcriptPath = parsed['transcript_path'];
283
+ if (typeof transcriptPath !== 'string' || !transcriptPath)
284
+ return body;
285
+ const usage = (0, transcript_usage_1.sumTranscriptUsage)(transcriptPath);
286
+ if (!usage)
287
+ return body;
288
+ return JSON.stringify({ ...parsed, _lumo_usage: usage });
289
+ }
263
290
  /**
264
291
  * POST the hook body to /api/hooks/<path> with a short timeout. All errors
265
292
  * — credential missing, network failure, timeout, non-2xx — are routed to
@@ -308,11 +335,12 @@ async function runHookWithBody(path, body, agentToken) {
308
335
  const agentEnum = agentToken ? (0, agent_1.normalizeAgent)(agentToken) : null;
309
336
  if (agentEnum)
310
337
  headers['X-Lumo-Agent'] = agentEnum;
338
+ const outgoingBody = augmentBodyWithUsage(path, body) || '{}';
311
339
  try {
312
340
  const res = await fetch(url, {
313
341
  method: 'POST',
314
342
  headers,
315
- body: body || '{}',
343
+ body: outgoingBody,
316
344
  signal: controller.signal,
317
345
  });
318
346
  if (!res.ok) {
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sumTranscriptUsage = sumTranscriptUsage;
4
+ const node_fs_1 = require("node:fs");
5
+ function asNumber(v) {
6
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
7
+ }
8
+ /**
9
+ * Read a Claude Code transcript JSONL and sum `message.usage` across all
10
+ * assistant entries -> cumulative session token totals. Best-effort: returns
11
+ * null if the file can't be read or has no assistant usage. Never throws.
12
+ */
13
+ function sumTranscriptUsage(transcriptPath) {
14
+ let text;
15
+ try {
16
+ text = (0, node_fs_1.readFileSync)(transcriptPath, 'utf8');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ const total = {
22
+ inputTokens: 0,
23
+ outputTokens: 0,
24
+ cacheReadTokens: 0,
25
+ cacheCreationTokens: 0,
26
+ };
27
+ let seen = false;
28
+ for (const raw of text.split('\n')) {
29
+ const line = raw.trim();
30
+ if (!line)
31
+ continue;
32
+ let obj;
33
+ try {
34
+ obj = JSON.parse(line);
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ if (!obj || typeof obj !== 'object')
40
+ continue;
41
+ const o = obj;
42
+ if (o.type !== 'assistant')
43
+ continue;
44
+ const usage = o.message?.usage;
45
+ if (!usage)
46
+ continue;
47
+ seen = true;
48
+ total.inputTokens += asNumber(usage.input_tokens);
49
+ total.outputTokens += asNumber(usage.output_tokens);
50
+ total.cacheReadTokens += asNumber(usage.cache_read_input_tokens);
51
+ total.cacheCreationTokens += asNumber(usage.cache_creation_input_tokens);
52
+ }
53
+ return seen ? total : null;
54
+ }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  // ── Agent Error types ────────────────────────────────────────────────────────
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
4
+ exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
5
5
  exports.userFriendlyError = userFriendlyError;
6
6
  class AgentError extends Error {
7
7
  code;
@@ -33,3 +33,6 @@ function userFriendlyError(code) {
33
33
  var markdown_tiptap_1 = require("./markdown-tiptap");
34
34
  Object.defineProperty(exports, "markdownToTiptap", { enumerable: true, get: function () { return markdown_tiptap_1.markdownToTiptap; } });
35
35
  Object.defineProperty(exports, "tiptapToMarkdown", { enumerable: true, get: function () { return markdown_tiptap_1.tiptapToMarkdown; } });
36
+ // ── Stream-json run usage parser ──────────────────────────────────────────────
37
+ var stream_json_usage_1 = require("./stream-json-usage");
38
+ Object.defineProperty(exports, "parseStreamJsonUsage", { enumerable: true, get: function () { return stream_json_usage_1.parseStreamJsonUsage; } });
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStreamJsonUsage = parseStreamJsonUsage;
4
+ function asNumber(v) {
5
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
6
+ }
7
+ /**
8
+ * Parse the final `type: "result"` message of a stream-json claude run.
9
+ * Accepts either the whole stdout log or a single result line. Scans for the
10
+ * LAST parseable line whose `type === 'result'` and lifts `usage.*` +
11
+ * `num_turns`. Returns null when no result line is present (agent crashed or
12
+ * timed out before emitting one) — callers leave the cost columns null.
13
+ */
14
+ function parseStreamJsonUsage(text) {
15
+ if (!text)
16
+ return null;
17
+ const lines = text.split('\n');
18
+ for (let i = lines.length - 1; i >= 0; i--) {
19
+ const line = lines[i]?.trim();
20
+ if (!line)
21
+ continue;
22
+ let obj;
23
+ try {
24
+ obj = JSON.parse(line);
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ if (obj &&
30
+ typeof obj === 'object' &&
31
+ obj.type === 'result') {
32
+ const r = obj;
33
+ const usage = r.usage ?? {};
34
+ return {
35
+ inputTokens: asNumber(usage['input_tokens']),
36
+ outputTokens: asNumber(usage['output_tokens']),
37
+ cacheReadTokens: asNumber(usage['cache_read_input_tokens']),
38
+ cacheCreationTokens: asNumber(usage['cache_creation_input_tokens']),
39
+ loopCount: asNumber(r.num_turns),
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",