@cruxy/cli 0.18.0 → 0.20.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.
Files changed (61) hide show
  1. package/dist/agent/loop.d.ts +12 -0
  2. package/dist/agent/loop.js +20 -0
  3. package/dist/agent/session.d.ts +18 -1
  4. package/dist/agent/session.js +38 -6
  5. package/dist/cli/commands/run.js +31 -1
  6. package/dist/cli/commands/usage.d.ts +9 -0
  7. package/dist/cli/commands/usage.js +81 -0
  8. package/dist/cli/program.js +2 -0
  9. package/dist/cli/session-factory.js +30 -1
  10. package/dist/config/schema.d.ts +407 -14
  11. package/dist/config/schema.js +77 -0
  12. package/dist/constants.d.ts +7 -0
  13. package/dist/constants.js +7 -0
  14. package/dist/errors/constructors.d.ts +30 -0
  15. package/dist/errors/constructors.js +86 -0
  16. package/dist/errors/types.d.ts +13 -0
  17. package/dist/errors/types.js +25 -0
  18. package/dist/lsp/client.d.ts +25 -0
  19. package/dist/lsp/client.js +43 -0
  20. package/dist/lsp/index.d.ts +8 -0
  21. package/dist/lsp/index.js +8 -0
  22. package/dist/lsp/pool.d.ts +48 -0
  23. package/dist/lsp/pool.js +132 -0
  24. package/dist/lsp/registry.d.ts +38 -0
  25. package/dist/lsp/registry.js +133 -0
  26. package/dist/lsp/server.d.ts +48 -0
  27. package/dist/lsp/server.js +264 -0
  28. package/dist/lsp/service.d.ts +44 -0
  29. package/dist/lsp/service.js +76 -0
  30. package/dist/lsp/tools/common.d.ts +23 -0
  31. package/dist/lsp/tools/common.js +75 -0
  32. package/dist/lsp/tools/find-definition.d.ts +23 -0
  33. package/dist/lsp/tools/find-definition.js +41 -0
  34. package/dist/lsp/tools/find-references.d.ts +23 -0
  35. package/dist/lsp/tools/find-references.js +41 -0
  36. package/dist/lsp/tools/get-diagnostics.d.ts +17 -0
  37. package/dist/lsp/tools/get-diagnostics.js +43 -0
  38. package/dist/lsp/tools/hover.d.ts +23 -0
  39. package/dist/lsp/tools/hover.js +38 -0
  40. package/dist/lsp/tools/index.d.ts +4 -0
  41. package/dist/lsp/tools/index.js +4 -0
  42. package/dist/lsp/transport.d.ts +48 -0
  43. package/dist/lsp/transport.js +264 -0
  44. package/dist/lsp/types.d.ts +107 -0
  45. package/dist/lsp/types.js +1 -0
  46. package/dist/plan/service.d.ts +10 -1
  47. package/dist/plan/service.js +2 -0
  48. package/dist/tools/file/grep-files.d.ts +2 -2
  49. package/dist/usage/collect.d.ts +40 -0
  50. package/dist/usage/collect.js +34 -0
  51. package/dist/usage/cost.d.ts +19 -0
  52. package/dist/usage/cost.js +29 -0
  53. package/dist/usage/index.d.ts +15 -0
  54. package/dist/usage/index.js +15 -0
  55. package/dist/usage/store.d.ts +37 -0
  56. package/dist/usage/store.js +83 -0
  57. package/dist/usage/summary.d.ts +32 -0
  58. package/dist/usage/summary.js +119 -0
  59. package/dist/usage/types.d.ts +220 -0
  60. package/dist/usage/types.js +47 -0
  61. package/package.json +1 -1
@@ -72,6 +72,18 @@ export interface RunAgentArgs {
72
72
  /** The declared task class for routing; defaults to `main-turn`. Ignored
73
73
  * unless `router` is set. */
74
74
  taskClass?: TaskClass;
75
+ /**
76
+ * Usage telemetry (C.22): fired ONCE per completed model request with the
77
+ * routing tier (C.30) and the provider's usage for THAT request — or
78
+ * `usage: undefined` when the provider emitted no usage event, so the caller
79
+ * records it as unknown (never a fabricated zero). LOCAL accounting only:
80
+ * this is a callback into the process, nothing is transmitted. Omitted → no
81
+ * collection, behavior unchanged.
82
+ */
83
+ onRequestUsage?: (req: {
84
+ tier?: string;
85
+ usage?: Usage;
86
+ }) => void;
75
87
  }
76
88
  /**
77
89
  * The budget seam for {@link runAgent}: implementations track their own caps
@@ -81,6 +81,12 @@ async function driveLoop(args, renderer, routed) {
81
81
  let turnText = "";
82
82
  const pending = new Map();
83
83
  const toolUses = [];
84
+ // Per-request usage capture for telemetry (C.22). `sawUsage` is the honesty
85
+ // pivot: a request that emits NO usage event stays `false`, so it is reported
86
+ // as unknown rather than a fabricated zero. A provider-reported 0 flips it
87
+ // true and is recorded as a real 0.
88
+ let sawUsage = false;
89
+ const reqUsage = { input_tokens: 0, output_tokens: 0 };
84
90
  // Live progress while waiting on the model; dismissed by the first delta.
85
91
  // Token context is whatever the loop has actually accumulated (U.4): zero
86
92
  // on the first turn → no figure shown, never a fabricated number.
@@ -121,6 +127,12 @@ async function driveLoop(args, renderer, routed) {
121
127
  case "usage":
122
128
  usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
123
129
  usage.output_tokens += ev.usage.output_tokens;
130
+ // Mirror the accumulation into the per-request figure the telemetry
131
+ // callback reports (same last-non-zero-in / summed-out semantics).
132
+ sawUsage = true;
133
+ reqUsage.input_tokens =
134
+ ev.usage.input_tokens || reqUsage.input_tokens;
135
+ reqUsage.output_tokens += ev.usage.output_tokens;
124
136
  break;
125
137
  case "message_stop":
126
138
  // Turn complete; the stream ends after this.
@@ -131,6 +143,14 @@ async function driveLoop(args, renderer, routed) {
131
143
  break;
132
144
  }
133
145
  }
146
+ // Request complete: report its usage honestly — the real figure when a usage
147
+ // event arrived, or `undefined` (unknown) when the provider reported none. A
148
+ // stream that threw above never reaches here, so failed requests aren't
149
+ // recorded with a misleading zero.
150
+ args.onRequestUsage?.({
151
+ tier: routed?.tier,
152
+ usage: sawUsage ? { ...reqUsage } : undefined,
153
+ });
134
154
  // ── Record the assistant turn ───────────────────────────────────────────
135
155
  if (turnText) {
136
156
  // Streaming (renderer set): the text already reached the user delta by
@@ -4,6 +4,7 @@ import type { StreamRenderer } from "../render/index.js";
4
4
  import { type Router } from "../routing/index.js";
5
5
  import type { ToolContext } from "../tools/index.js";
6
6
  import type { ToolRegistry } from "../tools/index.js";
7
+ import { type RequestUsage, type UsageRecord } from "../usage/index.js";
7
8
  import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
8
9
  /**
9
10
  * Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
@@ -16,6 +17,10 @@ export type PlanRunner = (args: {
16
17
  projectInstructions: string | null;
17
18
  recalledMemory: string | null;
18
19
  renderer?: StreamRenderer;
20
+ /** Usage telemetry (C.22): forwarded to every model request the plan-mode
21
+ * turn drives (propose + each execution step), so plan runs are attributed
22
+ * exactly like a normal turn. */
23
+ onRequestUsage?: (req: RequestUsage) => void;
19
24
  }) => Promise<AgentResult>;
20
25
  export interface SessionArgs {
21
26
  /** A constructed provider to stream from. */
@@ -56,6 +61,12 @@ export interface SessionArgs {
56
61
  * context compaction on `summarize`; omitted → the provider default (unchanged).
57
62
  */
58
63
  router?: Router;
64
+ /**
65
+ * Usage telemetry sink (C.22): called once per `send` with that run's
66
+ * {@link UsageRecord} (real per-request usage, tier-attributed). The sink
67
+ * persists it locally — it never transmits. Omitted → no persistence.
68
+ */
69
+ onRunUsage?: (record: UsageRecord) => void;
59
70
  }
60
71
  /**
61
72
  * Estimate the token footprint of a message list with a cheap chars/4 heuristic
@@ -80,6 +91,12 @@ export declare class Session {
80
91
  messages: Message[];
81
92
  /** Token usage summed across every `send` (and every compaction) in this session. */
82
93
  readonly usage: Usage;
94
+ /** Stable id for this session (C.22), so a run's usage record groups with the
95
+ * other runs of the same interactive session (`cruxy usage --session`). */
96
+ readonly sessionId: string;
97
+ /** The most recent run's usage record (C.22) — the one-shot path reads it to
98
+ * print the end-of-run summary. */
99
+ lastRun?: UsageRecord;
83
100
  private readonly args;
84
101
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
85
102
  private projectInstructions;
@@ -120,7 +137,7 @@ export declare class Session {
120
137
  * returns the number of older messages folded into the summary; otherwise
121
138
  * returns `null` (under threshold, nothing safe to cut, or summary failed).
122
139
  */
123
- maybeCompact(): Promise<number | null>;
140
+ maybeCompact(onRequestUsage?: (req: RequestUsage) => void): Promise<number | null>;
124
141
  /**
125
142
  * Force compaction regardless of the threshold (backs `/compact`). Returns the
126
143
  * number of older messages summarized, or `null` if there was nothing safe to
@@ -1,5 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { loadProjectInstructions } from "../config/index.js";
2
3
  import { resolveTaskModel } from "../routing/index.js";
4
+ import { UsageCollector, } from "../usage/index.js";
3
5
  import { runAgent, } from "./loop.js";
4
6
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
5
7
  /**
@@ -47,6 +49,12 @@ export class Session {
47
49
  messages = [];
48
50
  /** Token usage summed across every `send` (and every compaction) in this session. */
49
51
  usage = { input_tokens: 0, output_tokens: 0 };
52
+ /** Stable id for this session (C.22), so a run's usage record groups with the
53
+ * other runs of the same interactive session (`cruxy usage --session`). */
54
+ sessionId = randomUUID();
55
+ /** The most recent run's usage record (C.22) — the one-shot path reads it to
56
+ * print the end-of-run summary. */
57
+ lastRun;
50
58
  args;
51
59
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
52
60
  projectInstructions;
@@ -86,8 +94,15 @@ export class Session {
86
94
  */
87
95
  async send(userPrompt, renderer) {
88
96
  this.messages.push({ role: "user", content: userPrompt });
97
+ // Usage telemetry (C.22): one collector per run. `onReq` is threaded into
98
+ // every real model request this turn drives — the main loop, compaction, and
99
+ // (in plan mode) the propose + execution steps — so usage is captured exactly
100
+ // where the provider reports it, honestly (unknown when it reports nothing).
101
+ const collector = new UsageCollector();
102
+ const startedAt = new Date().toISOString();
103
+ const onReq = (req) => collector.record(req);
89
104
  // Compact *before* the agent call so the turn runs against a bounded history.
90
- await this.maybeCompact();
105
+ await this.maybeCompact(onReq);
91
106
  // before-run (C.19): a blocking pre-run hook — or an untrusted project's
92
107
  // hooks — throws here and aborts the turn before the model is engaged
93
108
  // (fail-closed). No-op when hooks are disabled or none are registered.
@@ -101,6 +116,7 @@ export class Session {
101
116
  projectInstructions: this.projectInstructions,
102
117
  recalledMemory: this.args.recalledMemory ?? null,
103
118
  renderer,
119
+ onRequestUsage: onReq,
104
120
  })
105
121
  : await runAgent({
106
122
  messages: this.messages,
@@ -110,10 +126,17 @@ export class Session {
110
126
  projectInstructions: this.projectInstructions,
111
127
  planMode: false, // the plan directive belongs only to the runner's propose phase
112
128
  renderer,
129
+ onRequestUsage: onReq,
113
130
  });
114
131
  this.messages = result.messages;
115
132
  this.usage.input_tokens += result.usage.input_tokens;
116
133
  this.usage.output_tokens += result.usage.output_tokens;
134
+ // Publish the run's usage record (C.22): stash it for the one-shot summary
135
+ // and hand it to the persistence sink. Building the record never touches the
136
+ // network and never blocks the turn's result.
137
+ const record = collector.toRecord(randomUUID(), this.sessionId, startedAt);
138
+ this.lastRun = record;
139
+ this.args.onRunUsage?.(record);
117
140
  // after-run (C.19): advisory by default (a blocking after-run hook throws
118
141
  // and surfaces at the boundary). The turn already completed and its history
119
142
  // is adopted above — an advisory failure never rewrites it.
@@ -139,12 +162,12 @@ export class Session {
139
162
  * returns the number of older messages folded into the summary; otherwise
140
163
  * returns `null` (under threshold, nothing safe to cut, or summary failed).
141
164
  */
142
- async maybeCompact() {
165
+ async maybeCompact(onRequestUsage) {
143
166
  const { maxTokens, compactThreshold } = this.args.config.context;
144
167
  if (estimateTokens(this.messages) <= compactThreshold * maxTokens) {
145
168
  return null;
146
169
  }
147
- const n = await this.runCompaction();
170
+ const n = await this.runCompaction(onRequestUsage);
148
171
  if (n) {
149
172
  this.args.ctx.logger.info(`compacted ${n} older message${n === 1 ? "" : "s"} to stay within context`);
150
173
  }
@@ -164,7 +187,7 @@ export class Session {
164
187
  * failed summary call leaves the history untouched and returns `null` (fail
165
188
  * open — losing compaction is degraded, not unsafe).
166
189
  */
167
- async runCompaction() {
190
+ async runCompaction(onRequestUsage) {
168
191
  const cut = this.findCut();
169
192
  if (cut === null)
170
193
  return null;
@@ -172,7 +195,7 @@ export class Session {
172
195
  const kept = this.messages.slice(cut);
173
196
  let synopsis;
174
197
  try {
175
- const summary = await this.summarize(prefix);
198
+ const summary = await this.summarize(prefix, onRequestUsage);
176
199
  synopsis = summary.text;
177
200
  this.usage.input_tokens += summary.usage.input_tokens;
178
201
  this.usage.output_tokens += summary.usage.output_tokens;
@@ -225,7 +248,7 @@ export class Session {
225
248
  * Summarize a prefix via a standalone, tool-less provider call over a rendered
226
249
  * transcript. Throws on a stream error or empty output so callers fail open.
227
250
  */
228
- async summarize(prefix) {
251
+ async summarize(prefix, onRequestUsage) {
229
252
  const transcript = renderTranscript(prefix);
230
253
  const usage = { input_tokens: 0, output_tokens: 0 };
231
254
  let text = "";
@@ -234,6 +257,9 @@ export class Session {
234
257
  const routed = this.args.router
235
258
  ? resolveTaskModel(this.args.router, "summarize")
236
259
  : null;
260
+ // Per-request usage capture for telemetry (C.22), same honesty pivot as the
261
+ // main loop: unknown unless a usage event actually arrives.
262
+ let sawUsage = false;
237
263
  for await (const ev of this.args.provider.stream({
238
264
  system: SUMMARY_SYSTEM,
239
265
  messages: [{ role: "user", content: transcript }],
@@ -246,6 +272,7 @@ export class Session {
246
272
  case "usage":
247
273
  usage.input_tokens = ev.usage.input_tokens || usage.input_tokens;
248
274
  usage.output_tokens += ev.usage.output_tokens;
275
+ sawUsage = true;
249
276
  break;
250
277
  case "error":
251
278
  throw ev.error;
@@ -253,6 +280,11 @@ export class Session {
253
280
  break;
254
281
  }
255
282
  }
283
+ // Attribute this compaction request to the `summarize` tier honestly.
284
+ onRequestUsage?.({
285
+ tier: routed?.tier,
286
+ usage: sawUsage ? { ...usage } : undefined,
287
+ });
256
288
  if (!text.trim())
257
289
  throw new Error("summary was empty");
258
290
  return { text: text.trim(), usage };
@@ -4,12 +4,14 @@ import { loadConfig, resolveApiKey } from "../../config/index.js";
4
4
  import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
5
5
  import { createRenderer } from "../../render/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
+ import { summarizeRuns, renderSummary, } from "../../usage/index.js";
7
8
  import { CheckpointService } from "../../checkpoint/index.js";
8
9
  import { SandboxService } from "../../sandbox/index.js";
9
10
  import { buildHooksService } from "../../hooks/index.js";
10
11
  import { runInteractive } from "../repl.js";
11
12
  import { buildAgentSession } from "../session-factory.js";
12
13
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
14
+ import { resetLspServices } from "../../lsp/index.js";
13
15
  export function runCommand() {
14
16
  return new Command("run")
15
17
  .description("run a task once, or start an interactive session")
@@ -91,7 +93,14 @@ export function runCommand() {
91
93
  });
92
94
  const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner);
93
95
  if (interactive) {
94
- await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
96
+ try {
97
+ await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
98
+ }
99
+ finally {
100
+ // LSP (C.12): gracefully shut down any language servers spawned
101
+ // during the session (the process-exit kill-tree is the fail-safe).
102
+ await resetLspServices();
103
+ }
95
104
  return;
96
105
  }
97
106
  checkpoints?.beginRun(prompt);
@@ -109,6 +118,27 @@ export function runCommand() {
109
118
  }
110
119
  finally {
111
120
  renderer.close();
121
+ // LSP (C.12): gracefully shut down any language servers spawned during
122
+ // the run (the process-exit kill-tree is the fail-safe for a hard kill).
123
+ await resetLspServices();
124
+ }
125
+ // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
126
+ // cost (only when priced). Printed after the live region is torn down.
127
+ // Reaches here only on success — a thrown run propagates past it — and
128
+ // never affects the run's outcome.
129
+ if (config.usage.enabled && session.lastRun) {
130
+ printRunUsage(session.lastRun, config);
112
131
  }
113
132
  });
114
133
  }
134
+ /** Render the just-finished run's usage as a single themed line (C.22). */
135
+ function printRunUsage(record, config) {
136
+ if (record.entries.length === 0)
137
+ return;
138
+ const t = themeForColor(shouldUseColor(process.stdout));
139
+ const summary = summarizeRuns([record], {
140
+ prices: config.usage.prices,
141
+ currency: config.usage.currency,
142
+ });
143
+ logger.print(renderSummary(summary, t));
144
+ }
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy usage` (C.22) — show LOCAL token usage and (when priced) cost, read
4
+ * back from `~/.cruxy/usage`. Read-only and local: it prints your own accounting
5
+ * and transmits nothing. Every figure is real — a request the provider never
6
+ * reported usage for is shown as unreported, never a fabricated number, and cost
7
+ * appears only for tiers you have priced.
8
+ */
9
+ export declare function usageCommand(): Command;
@@ -0,0 +1,81 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../../config/index.js";
3
+ import { shouldUseColor, usageError } from "../../errors/index.js";
4
+ import { themeForColor } from "../../theme/index.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { loadUsage, summarizeRuns, renderSummary, } from "../../usage/index.js";
7
+ /**
8
+ * `cruxy usage` (C.22) — show LOCAL token usage and (when priced) cost, read
9
+ * back from `~/.cruxy/usage`. Read-only and local: it prints your own accounting
10
+ * and transmits nothing. Every figure is real — a request the provider never
11
+ * reported usage for is shown as unreported, never a fabricated number, and cost
12
+ * appears only for tiers you have priced.
13
+ */
14
+ export function usageCommand() {
15
+ return new Command("usage")
16
+ .description("show token usage and cost for the session and recent runs")
17
+ .option("--session", "only the most recent session's runs")
18
+ .option("--last <n>", "only the last N runs")
19
+ .action((opts) => {
20
+ const t = themeForColor(shouldUseColor(process.stdout));
21
+ const { config } = loadConfig();
22
+ // A corrupt/unreadable store is surfaced as CRUXY_E_USAGE_READ with an
23
+ // actionable fix — it is not silently ignored, and it never crashes.
24
+ const { data, error } = loadUsage();
25
+ if (error)
26
+ throw error;
27
+ const scoped = selectRuns(data.runs, opts);
28
+ if (scoped.length === 0) {
29
+ logger.print(t.muted("no usage recorded yet"));
30
+ if (!config.usage.enabled) {
31
+ logger.print(t.muted("usage tracking is off (usage.enabled = false)"));
32
+ }
33
+ return;
34
+ }
35
+ const summary = summarizeRuns(scoped, {
36
+ prices: config.usage.prices,
37
+ currency: config.usage.currency,
38
+ });
39
+ const scopeLabel = opts.session
40
+ ? "current session"
41
+ : opts.last
42
+ ? `last ${scoped.length} run${scoped.length === 1 ? "" : "s"}`
43
+ : `all ${scoped.length} run${scoped.length === 1 ? "" : "s"}`;
44
+ logger.print(t.heading(`usage — ${scopeLabel}`));
45
+ logger.print(renderSummary(summary, t));
46
+ // State when NO price is configured, so an absent cost never reads as $0.
47
+ if (!summary.priced) {
48
+ logger.print(t.muted("cost omitted — no prices configured (set usage.prices.<tier>.{input,output}, per million tokens)"));
49
+ }
50
+ });
51
+ }
52
+ /**
53
+ * Narrow the stored runs to the requested scope. `--last N` keeps the newest N;
54
+ * `--session` keeps the runs sharing the most recent run's session id; no flag
55
+ * keeps everything retained. `--session` and `--last` are mutually exclusive.
56
+ */
57
+ function selectRuns(runs, opts) {
58
+ if (opts.session && opts.last !== undefined) {
59
+ throw usageError("pass only one of --session or --last", [
60
+ "cruxy usage --session (the most recent session)",
61
+ "cruxy usage --last 5 (the last 5 runs)",
62
+ ]);
63
+ }
64
+ if (opts.last !== undefined) {
65
+ const n = Number(opts.last);
66
+ if (!Number.isInteger(n) || n <= 0) {
67
+ throw usageError(`--last must be a positive integer (got "${opts.last}")`);
68
+ }
69
+ return runs.slice(-n);
70
+ }
71
+ if (opts.session) {
72
+ const latest = runs[runs.length - 1];
73
+ if (!latest)
74
+ return [];
75
+ // Runs without a session id can't be grouped; scope to the latest run alone.
76
+ if (latest.sessionId === undefined)
77
+ return [latest];
78
+ return runs.filter((r) => r.sessionId === latest.sessionId);
79
+ }
80
+ return [...runs];
81
+ }
@@ -16,6 +16,7 @@ import { rollbackCommand } from "./commands/rollback.js";
16
16
  import { testCommand } from "./commands/test.js";
17
17
  import { hooksCommand } from "./commands/hooks.js";
18
18
  import { memoryCommand } from "./commands/memory.js";
19
+ import { usageCommand } from "./commands/usage.js";
19
20
  import { loadConfig } from "../config/index.js";
20
21
  import { maybeRunOnboarding } from "./onboard.js";
21
22
  export function buildProgram() {
@@ -47,6 +48,7 @@ export function buildProgram() {
47
48
  program.addCommand(testCommand());
48
49
  program.addCommand(hooksCommand());
49
50
  program.addCommand(memoryCommand());
51
+ program.addCommand(usageCommand());
50
52
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
51
53
  // means an unknown command (Commander runs the default action with it as an
52
54
  // operand rather than erroring), so reject it as a usage error.
@@ -9,6 +9,8 @@ import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
10
  import { routerForConfig } from "../routing/index.js";
11
11
  import { MemoryService, rememberTool } from "../memory/index.js";
12
+ import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
13
+ import { appendRun } from "../usage/index.js";
12
14
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
13
15
  /**
14
16
  * Wrap a PromptIO so the live region yields before any prompt text lands
@@ -102,6 +104,19 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
102
104
  // session, so the default path threads `undefined` and behaves exactly as
103
105
  // before. One router is shared by the main loop, subagents, and plan mode.
104
106
  const router = routerForConfig(config) ?? undefined;
107
+ // Usage telemetry (C.22): when enabled, each run's usage record is persisted
108
+ // to the LOCAL store. Best-effort and non-fatal — a corrupt/unwritable store
109
+ // is downgraded to a warning (CRUXY_E_USAGE_READ) and NEVER takes a run down.
110
+ // Nothing is transmitted. Off → the sink is undefined and no usage is written.
111
+ const onRunUsage = config.usage.enabled
112
+ ? (record) => {
113
+ const { error } = appendRun(record, {
114
+ retention: config.usage.retention,
115
+ });
116
+ if (error)
117
+ logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
118
+ }
119
+ : undefined;
105
120
  const execRegistry = buildDefaultRegistry();
106
121
  const git = getGitInfo(cwd);
107
122
  const projectInstructions = loadProjectInstructions(cwd);
@@ -122,6 +137,17 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
122
137
  logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
123
138
  }
124
139
  }
140
+ // Per-language LSP (C.12): register the four read-only introspection tools
141
+ // only when enabled. They spawn and manage EXTERNAL language-server processes,
142
+ // so — like the sandbox — the feature is opt-in; when off, none is registered
143
+ // and no server ever spawns. Read-only (no approval), so they bypass the U.3
144
+ // gate like search_codebase and are available to subagents and plan proposals.
145
+ if (config.lsp.enabled) {
146
+ execRegistry.register(findDefinitionTool);
147
+ execRegistry.register(findReferencesTool);
148
+ execRegistry.register(getDiagnosticsTool);
149
+ execRegistry.register(hoverTool);
150
+ }
125
151
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
126
152
  // and any gate inside a subagent), so they all coordinate with the same live
127
153
  // region. The full wrapper stack around an ApprovalService is factored here
@@ -168,7 +194,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
168
194
  requestApproval: gate(approval),
169
195
  sandbox,
170
196
  };
171
- const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, }) => runPlanSession({
197
+ const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
172
198
  provider,
173
199
  config,
174
200
  ctx,
@@ -182,6 +208,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
182
208
  recalledMemory: turnMemory,
183
209
  renderer: turnRenderer,
184
210
  router,
211
+ onRequestUsage,
185
212
  });
186
213
  return new Session({
187
214
  provider,
@@ -195,6 +222,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
195
222
  planRunner,
196
223
  hooks,
197
224
  router,
225
+ onRunUsage,
198
226
  });
199
227
  }
200
228
  const approval = new ApprovalService({
@@ -213,5 +241,6 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
213
241
  recalledMemory,
214
242
  hooks,
215
243
  router,
244
+ onRunUsage,
216
245
  });
217
246
  }