@juspay/neurolink 11.21.4 → 11.22.1

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.
@@ -29,10 +29,16 @@ export function addOllamaCommands(cli) {
29
29
  .demandCommand(1, "Please specify a command");
30
30
  }, () => { });
31
31
  }
32
+ /** See the note on the identically-named constant in ../utils/ollamaUtils.ts. */
33
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
32
34
  async function listModelsHandler() {
33
35
  const spinner = ora("Fetching installed models...").start();
34
36
  try {
35
- const res = spawnSync("ollama", ["list"], { encoding: "utf8" });
37
+ const res = spawnSync("ollama", ["list"], {
38
+ encoding: "utf8",
39
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
40
+ killSignal: "SIGKILL",
41
+ });
36
42
  if (res.error) {
37
43
  throw res.error;
38
44
  }
@@ -58,6 +64,9 @@ async function pullModelHandler(argv) {
58
64
  logger.always(chalk.blue(`Downloading model: ${model}`));
59
65
  logger.always(chalk.gray("This may take several minutes..."));
60
66
  try {
67
+ // Deliberately unbounded: a model pull is hundreds of megabytes and
68
+ // legitimately runs for many minutes. It also inherits stdio, so the user
69
+ // sees progress and can Ctrl-C — the two things a wedged query lacks.
61
70
  const res = spawnSync("ollama", ["pull", model], { stdio: "inherit" });
62
71
  if (res.error) {
63
72
  throw res.error;
@@ -91,7 +100,11 @@ async function removeModelHandler(argv) {
91
100
  }
92
101
  const spinner = ora(`Removing model ${model}...`).start();
93
102
  try {
94
- const res = spawnSync("ollama", ["rm", model], { encoding: "utf8" });
103
+ const res = spawnSync("ollama", ["rm", model], {
104
+ encoding: "utf8",
105
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
106
+ killSignal: "SIGKILL",
107
+ });
95
108
  if (res.error) {
96
109
  throw res.error;
97
110
  }
@@ -110,7 +123,11 @@ async function removeModelHandler(argv) {
110
123
  async function statusHandler() {
111
124
  const spinner = ora("Checking Ollama service status...").start();
112
125
  try {
113
- const res = spawnSync("ollama", ["list"], { encoding: "utf8" });
126
+ const res = spawnSync("ollama", ["list"], {
127
+ encoding: "utf8",
128
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
129
+ killSignal: "SIGKILL",
130
+ });
114
131
  if (res.error) {
115
132
  throw res.error;
116
133
  }
@@ -134,22 +151,42 @@ async function stopHandler() {
134
151
  try {
135
152
  if (process.platform === "darwin") {
136
153
  try {
137
- spawnSync("pkill", ["ollama"], { encoding: "utf8" });
154
+ spawnSync("pkill", ["ollama"], {
155
+ encoding: "utf8",
156
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
157
+ killSignal: "SIGKILL",
158
+ });
138
159
  }
139
160
  catch {
140
- spawnSync("killall", ["Ollama"], { encoding: "utf8" });
161
+ spawnSync("killall", ["Ollama"], {
162
+ encoding: "utf8",
163
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
164
+ killSignal: "SIGKILL",
165
+ });
141
166
  }
142
167
  }
143
168
  else if (process.platform === "linux") {
144
169
  try {
145
- spawnSync("systemctl", ["stop", "ollama"], { encoding: "utf8" });
170
+ spawnSync("systemctl", ["stop", "ollama"], {
171
+ encoding: "utf8",
172
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
173
+ killSignal: "SIGKILL",
174
+ });
146
175
  }
147
176
  catch {
148
- spawnSync("pkill", ["ollama"], { encoding: "utf8" });
177
+ spawnSync("pkill", ["ollama"], {
178
+ encoding: "utf8",
179
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
180
+ killSignal: "SIGKILL",
181
+ });
149
182
  }
150
183
  }
151
184
  else {
152
- spawnSync("taskkill", ["/F", "/IM", "ollama.exe"], { encoding: "utf8" });
185
+ spawnSync("taskkill", ["/F", "/IM", "ollama.exe"], {
186
+ encoding: "utf8",
187
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
188
+ killSignal: "SIGKILL",
189
+ });
153
190
  }
154
191
  spinner.succeed("Ollama service stopped");
155
192
  }
@@ -165,7 +202,11 @@ async function setupHandler() {
165
202
  const checkSpinner = ora("Checking Ollama installation...").start();
166
203
  let isInstalled = false;
167
204
  try {
168
- spawnSync("ollama", ["--version"], { encoding: "utf8" });
205
+ spawnSync("ollama", ["--version"], {
206
+ encoding: "utf8",
207
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
208
+ killSignal: "SIGKILL",
209
+ });
169
210
  isInstalled = true;
170
211
  checkSpinner.succeed("Ollama is installed");
171
212
  }
@@ -204,7 +245,11 @@ async function setupHandler() {
204
245
  // Check if service is running
205
246
  let serviceRunning = false;
206
247
  try {
207
- spawnSync("ollama", ["list"], { encoding: "utf8" });
248
+ spawnSync("ollama", ["list"], {
249
+ encoding: "utf8",
250
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
251
+ killSignal: "SIGKILL",
252
+ });
208
253
  serviceRunning = true;
209
254
  logger.always(chalk.green("\n✅ Ollama service is running"));
210
255
  }
@@ -0,0 +1,17 @@
1
+ import type { CommandModule } from "yargs";
2
+ import type { LocalUsageCommandArgs } from "../../types/index.js";
3
+ /**
4
+ * `neurolink usage local` — token spend read from each CLI's own session logs.
5
+ *
6
+ * The proxy's ledger only sees traffic that went through it, which is a
7
+ * fraction of what a developer actually spends: it depends on each vendor
8
+ * shipping a base-URL override, and most do not. Every CLI writes a local
9
+ * transcript regardless, so this reads those instead — no auth, no vendor
10
+ * cooperation, no proxy in the request path, and it recovers history from
11
+ * before the proxy was ever installed.
12
+ */
13
+ export declare class UsageCommandFactory {
14
+ static createUsageCommands(): CommandModule<object, LocalUsageCommandArgs>;
15
+ private static formatTokens;
16
+ private static executeLocal;
17
+ }
@@ -0,0 +1,146 @@
1
+ import chalk from "chalk";
2
+ import { logger } from "../../utils/logger.js";
3
+ /**
4
+ * `neurolink usage local` — token spend read from each CLI's own session logs.
5
+ *
6
+ * The proxy's ledger only sees traffic that went through it, which is a
7
+ * fraction of what a developer actually spends: it depends on each vendor
8
+ * shipping a base-URL override, and most do not. Every CLI writes a local
9
+ * transcript regardless, so this reads those instead — no auth, no vendor
10
+ * cooperation, no proxy in the request path, and it recovers history from
11
+ * before the proxy was ever installed.
12
+ */
13
+ export class UsageCommandFactory {
14
+ static createUsageCommands() {
15
+ return {
16
+ command: "usage <subcommand>",
17
+ describe: "Token usage read from local CLI session logs",
18
+ builder: (yargs) => yargs.command({
19
+ command: "local",
20
+ describe: "Summarise token spend from each installed CLI's own session logs",
21
+ builder: (sub) => sub
22
+ .option("since", {
23
+ type: "number",
24
+ default: 30,
25
+ description: "Only read sessions modified within this many days (0 = all history)",
26
+ })
27
+ .option("cli", {
28
+ type: "string",
29
+ description: "Limit to one CLI id (e.g. claude-code, codex, opencode)",
30
+ })
31
+ .option("json", {
32
+ type: "boolean",
33
+ default: false,
34
+ description: "Emit the raw report as JSON",
35
+ }),
36
+ handler: async (argv) => {
37
+ // Single assertion, not a double. yargs types the sub-builder's
38
+ // argv structurally; the fields below are the ones the builder
39
+ // declares, so this stays overlap-checked by the compiler.
40
+ await UsageCommandFactory.executeLocal({
41
+ since: Number(argv.since ?? 30),
42
+ json: Boolean(argv.json),
43
+ ...(typeof argv.cli === "string" ? { cli: argv.cli } : {}),
44
+ });
45
+ },
46
+ }),
47
+ handler: () => {
48
+ // yargs prints subcommand help when none is given.
49
+ },
50
+ };
51
+ }
52
+ static formatTokens(value) {
53
+ // Plain grouped digits rather than 1.2M: these are billing-adjacent
54
+ // figures and a reader comparing two rows needs the magnitudes to line up,
55
+ // not to be rounded into looking similar.
56
+ return value.toLocaleString("en-US");
57
+ }
58
+ static async executeLocal(argv) {
59
+ const { readAllLocalUsage, getLocalUsageDescriptors } = await import("../../localUsage/index.js");
60
+ // `--since 0` means all history — Infinity is the reader's sentinel for
61
+ // "no time filter", but 0 is what a person types. A NEGATIVE value is a
62
+ // mistake and must be rejected rather than folded in with 0: the previous
63
+ // expression sent -1 down the all-history path, so a typo produced the
64
+ // most expensive possible scan while the option's own help text says 0 is
65
+ // the way to ask for that.
66
+ if (!Number.isFinite(argv.since) || argv.since < 0) {
67
+ console.error(chalk.red(`--since must be zero or greater (0 means all history). Received: ${String(argv.since)}`));
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ const sinceDays = argv.since > 0 ? argv.since : Infinity;
72
+ // Validate BEFORE scanning. A typo should cost nothing, not a full sweep
73
+ // of every store followed by an empty result.
74
+ // `undefined` means the flag was not given. An empty string means it was
75
+ // given with no value, which is a mistake and must be rejected — a
76
+ // truthiness check treats the two as the same and silently scans every
77
+ // reader instead, reporting everything for a request that named nothing.
78
+ const wanted = argv.cli;
79
+ const known = getLocalUsageDescriptors().map((d) => d.id);
80
+ if (wanted !== undefined &&
81
+ !known.includes(wanted)) {
82
+ console.error(chalk.red(`Unknown CLI "${wanted}". Known readers: ${known.join(", ")}`));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ const report = await readAllLocalUsage({
87
+ sinceDays,
88
+ // Passed down so only the requested reader opens its store at all.
89
+ ...(wanted !== undefined
90
+ ? { only: [wanted] }
91
+ : {}),
92
+ });
93
+ const rows = Object.entries(report.totals);
94
+ if (argv.json) {
95
+ logger.always(JSON.stringify(wanted ? { ...report, totals: Object.fromEntries(rows) } : report, null, 2));
96
+ return;
97
+ }
98
+ const window = sinceDays === Infinity ? "all history" : `last ${argv.since} days`;
99
+ logger.always(chalk.bold(`\nLocal CLI token usage — ${window}\n`));
100
+ // A reader that ran and found nothing in the window is a different fact
101
+ // from one that is not installed, and from one that failed. Printing a
102
+ // block of zeros for it buries the rows that matter, so it gets one line.
103
+ const quiet = rows.filter(([, t]) => t && t.requests === 0);
104
+ const active = rows.filter(([, t]) => t && t.requests > 0);
105
+ for (const [cliId, totals] of active) {
106
+ if (!totals) {
107
+ continue;
108
+ }
109
+ const cached = totals.cacheReadTokens + totals.cacheCreationTokens;
110
+ logger.always(chalk.cyan(` ${cliId}`));
111
+ logger.always(` turns ${UsageCommandFactory.formatTokens(totals.requests)}`);
112
+ logger.always(` input ${UsageCommandFactory.formatTokens(totals.inputTokens)}` +
113
+ ` output ${UsageCommandFactory.formatTokens(totals.outputTokens)}` +
114
+ ` cached ${UsageCommandFactory.formatTokens(cached)}`);
115
+ // Cost and its confidence are printed together, always. A dollar figure
116
+ // shown without saying how it was arrived at is the thing this whole
117
+ // subsystem is trying not to do: "unavailable" means the CLI is a
118
+ // subscription and a per-token price would be invented, not that the
119
+ // lookup failed.
120
+ if (totals.costConfidence === "modeled") {
121
+ logger.always(` cost ${chalk.green(`$${totals.costUsd.toFixed(2)}`)} (modeled)` +
122
+ (totals.unpricedRequests > 0
123
+ ? chalk.dim(` — ${totals.unpricedRequests} turns unpriced: ${totals.unpricedModels.join(", ")}`)
124
+ : ""));
125
+ }
126
+ else {
127
+ logger.always(` cost ${chalk.dim("unavailable")} ` +
128
+ chalk.dim(totals.costConfidence === "heuristic"
129
+ ? "(estimated, not measured)"
130
+ : "(subscription — a per-token price would be invented)"));
131
+ }
132
+ logger.always("");
133
+ }
134
+ if (quiet.length > 0) {
135
+ logger.always(chalk.dim(` no usage in this window: ${quiet.map(([id]) => id).join(", ")}`));
136
+ }
137
+ if (report.notInstalled.length > 0) {
138
+ logger.always(chalk.dim(` not installed: ${report.notInstalled.join(", ")}`));
139
+ }
140
+ for (const failure of report.failures) {
141
+ logger.always(chalk.yellow(` ${failure.cliId} failed: ${failure.message}`));
142
+ }
143
+ logger.always("");
144
+ }
145
+ }
146
+ //# sourceMappingURL=usage.js.map
@@ -1,7 +1,4 @@
1
1
  import type { CommandModule } from "yargs";
2
- /**
3
- * Factory for creating Ollama CLI commands using the Factory Pattern
4
- */
5
2
  export declare class OllamaCommandFactory {
6
3
  /**
7
4
  * Secure wrapper around spawnSync to prevent command injection.
@@ -10,6 +7,17 @@ export declare class OllamaCommandFactory {
10
7
  /**
11
8
  * Create the Ollama command group
12
9
  */
10
+ /**
11
+ * Every handler is registered `.bind(this)`.
12
+ *
13
+ * yargs invokes the handler as a plain function, so a bare `this.xHandler`
14
+ * reference arrives with `this` unset and the first `this.safeSpawn(...)`
15
+ * throws `TypeError: this.safeSpawn is not a function`. That made EVERY
16
+ * subcommand here fail on its first line — list-models, pull, remove, status
17
+ * and stop all reported their generic "Failed to ..." message regardless of
18
+ * whether Ollama was installed or running, which is exactly why the real
19
+ * cause stayed hidden.
20
+ */
13
21
  static createOllamaCommands(): CommandModule;
14
22
  /**
15
23
  * Handler for listing installed models
@@ -9,6 +9,17 @@ import { AIProviderName } from "../../types/index.js";
9
9
  /**
10
10
  * Factory for creating Ollama CLI commands using the Factory Pattern
11
11
  */
12
+ /** See the note on the identically-named constant in cli/utils/ollamaUtils.ts. */
13
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
14
+ /**
15
+ * Explicit opt-out from the query bound, for commands that are long by design.
16
+ *
17
+ * `spawnSync` treats 0 as "no timeout" (verified: a 3s child under
18
+ * `timeout: 0` runs to completion with no error, while `timeout: 1000` returns
19
+ * at 1.0s with ETIMEDOUT). Named rather than written as a bare 0 at the call
20
+ * site, because `timeout: 0` reads like "expire immediately".
21
+ */
22
+ const OLLAMA_NO_TIMEOUT = 0;
12
23
  export class OllamaCommandFactory {
13
24
  /**
14
25
  * Secure wrapper around spawnSync to prevent command injection.
@@ -16,6 +27,12 @@ export class OllamaCommandFactory {
16
27
  static safeSpawn(command, args, options = {}) {
17
28
  // Command validation is now handled by TypeScript with AllowedCommand type
18
29
  const defaultOptions = {
30
+ // spawnSync blocks the event loop, so an unresponsive daemon hangs the
31
+ // whole CLI with no output and no error. SIGKILL because spawnSync's
32
+ // timeout otherwise sends SIGTERM and keeps waiting. Callers with
33
+ // legitimately long commands override via `options`.
34
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
35
+ killSignal: "SIGKILL",
19
36
  ...options,
20
37
  encoding: "utf8", // Always enforce utf8 encoding
21
38
  };
@@ -24,31 +41,42 @@ export class OllamaCommandFactory {
24
41
  /**
25
42
  * Create the Ollama command group
26
43
  */
44
+ /**
45
+ * Every handler is registered `.bind(this)`.
46
+ *
47
+ * yargs invokes the handler as a plain function, so a bare `this.xHandler`
48
+ * reference arrives with `this` unset and the first `this.safeSpawn(...)`
49
+ * throws `TypeError: this.safeSpawn is not a function`. That made EVERY
50
+ * subcommand here fail on its first line — list-models, pull, remove, status
51
+ * and stop all reported their generic "Failed to ..." message regardless of
52
+ * whether Ollama was installed or running, which is exactly why the real
53
+ * cause stayed hidden.
54
+ */
27
55
  static createOllamaCommands() {
28
56
  return {
29
57
  command: "ollama <command>",
30
58
  describe: "Manage Ollama local AI models",
31
59
  builder: (yargs) => {
32
60
  return yargs
33
- .command("list-models", "List installed Ollama models", {}, this.listModelsHandler)
61
+ .command("list-models", "List installed Ollama models", {}, this.listModelsHandler.bind(this))
34
62
  .command("pull <model>", "Download an Ollama model", {
35
63
  model: {
36
64
  describe: "Model name to download",
37
65
  type: "string",
38
66
  demandOption: true,
39
67
  },
40
- }, this.pullModelHandler)
68
+ }, this.pullModelHandler.bind(this))
41
69
  .command("remove <model>", "Remove an Ollama model", {
42
70
  model: {
43
71
  describe: "Model name to remove",
44
72
  type: "string",
45
73
  demandOption: true,
46
74
  },
47
- }, this.removeModelHandler)
48
- .command("status", "Check Ollama service status", {}, this.statusHandler)
49
- .command("start", "Start Ollama service", {}, this.startHandler)
50
- .command("stop", "Stop Ollama service", {}, this.stopHandler)
51
- .command("setup", "Interactive Ollama setup", {}, this.setupHandler)
75
+ }, this.removeModelHandler.bind(this))
76
+ .command("status", "Check Ollama service status", {}, this.statusHandler.bind(this))
77
+ .command("start", "Start Ollama service", {}, this.startHandler.bind(this))
78
+ .command("stop", "Stop Ollama service", {}, this.stopHandler.bind(this))
79
+ .command("setup", "Interactive Ollama setup", {}, this.setupHandler.bind(this))
52
80
  .demandCommand(1, "Please specify a command");
53
81
  },
54
82
  handler: () => { }, // No-op handler as subcommands handle everything
@@ -89,8 +117,16 @@ export class OllamaCommandFactory {
89
117
  logger.always(chalk.blue(`Downloading model: ${model}`));
90
118
  logger.always(chalk.gray("This may take several minutes..."));
91
119
  try {
120
+ // A model pull is hundreds of megabytes and legitimately runs for many
121
+ // minutes, so it MUST opt out of safeSpawn's default query bound.
122
+ // Passing only `stdio` here is what made the first revision of this
123
+ // change kill downloads at 15s: the wrapper spreads `options` over its
124
+ // defaults, so an unmentioned `timeout` keeps the default rather than
125
+ // being absent. It inherits stdio, so the user sees progress and can
126
+ // interrupt it — the two things a wedged query lacks.
92
127
  const res = this.safeSpawn("ollama", ["pull", model], {
93
128
  stdio: "inherit",
129
+ timeout: OLLAMA_NO_TIMEOUT,
94
130
  });
95
131
  if (res.error || res.status !== 0) {
96
132
  throw res.error || new Error("pull failed");
@@ -211,25 +247,36 @@ export class OllamaCommandFactory {
211
247
  */
212
248
  static async stopHandler() {
213
249
  const spinner = ora("Stopping Ollama service...").start();
250
+ // `safeSpawn` RETURNS a result; it does not throw on a failed command. So
251
+ // the try/catch fallbacks that used to sit here never fired — `killall`
252
+ // and the pkill fallback were unreachable, and the handler reported
253
+ // "stopped" whatever happened. Bounding these calls made that worse rather
254
+ // than better: a timeout now yields `error: ETIMEDOUT` with `status: null`,
255
+ // which is still not a throw, so a wedged kill would be reported as a
256
+ // successful stop while Ollama stayed up.
257
+ //
258
+ // Branch on the result instead, which both restores the fallback and makes
259
+ // the success message conditional on something actually succeeding.
260
+ const ok = (r) => !r.error && r.status === 0;
214
261
  try {
262
+ let stopped;
215
263
  if (process.platform === "darwin") {
216
- try {
217
- this.safeSpawn("pkill", ["ollama"]);
218
- }
219
- catch {
220
- this.safeSpawn("killall", ["Ollama"]);
221
- }
264
+ stopped =
265
+ ok(this.safeSpawn("pkill", ["ollama"])) ||
266
+ ok(this.safeSpawn("killall", ["Ollama"]));
222
267
  }
223
268
  else if (process.platform === "linux") {
224
- try {
225
- this.safeSpawn("systemctl", ["stop", "ollama"]);
226
- }
227
- catch {
228
- this.safeSpawn("pkill", ["ollama"]);
229
- }
269
+ stopped =
270
+ ok(this.safeSpawn("systemctl", ["stop", "ollama"])) ||
271
+ ok(this.safeSpawn("pkill", ["ollama"]));
230
272
  }
231
273
  else {
232
- this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"]);
274
+ stopped = ok(this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"]));
275
+ }
276
+ if (!stopped) {
277
+ spinner.fail("Could not confirm Ollama service stopped");
278
+ logger.error(chalk.red("No stop command succeeded — it may not have been running, or it may still be up. Check with: ollama list"));
279
+ return;
233
280
  }
234
281
  spinner.succeed("Ollama service stopped");
235
282
  }
@@ -29,6 +29,7 @@ import { TaskCommandFactory } from "./commands/task.js";
29
29
  import { AutoresearchCommandFactory } from "./commands/autoresearch.js";
30
30
  import { voiceServerCommand } from "./commands/voiceServer.js";
31
31
  import { DocsCommandFactory } from "./commands/docs.js";
32
+ import { UsageCommandFactory } from "./commands/usage.js";
32
33
  // Enhanced CLI with Professional UX
33
34
  export function initializeCliParser() {
34
35
  return (yargs(hideBin(process.argv))
@@ -156,6 +157,7 @@ export function initializeCliParser() {
156
157
  .command(CLICommandFactory.createGenerateCommand())
157
158
  // Docs MCP Server Command
158
159
  .command(DocsCommandFactory.createDocsCommand())
160
+ .command(UsageCommandFactory.createUsageCommands())
159
161
  // Stream Text Command - Using CLICommandFactory
160
162
  .command(CLICommandFactory.createStreamCommand())
161
163
  // Batch Processing Command - Using CLICommandFactory
@@ -8,6 +8,15 @@ export declare class OllamaUtils {
8
8
  * Secure wrapper around spawnSync to prevent command injection.
9
9
  */
10
10
  static safeSpawn(command: AllowedCommand, args: string[], options?: SpawnSyncOptions): SpawnSyncReturns<string>;
11
+ /**
12
+ * Whether a `safeSpawn` call actually succeeded.
13
+ *
14
+ * `spawnSync` reports failure by RETURNING — `error` set (ENOENT, ETIMEDOUT)
15
+ * or a non-zero `status` — never by throwing. Every `try/catch` around one of
16
+ * these calls was therefore dead code, which is why several fallbacks in this
17
+ * file had never run.
18
+ */
19
+ private static spawnSucceeded;
11
20
  /**
12
21
  * Check if Ollama command line is available
13
22
  */
@@ -2,6 +2,32 @@ import { spawnSync, spawn, } from "child_process";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { logger } from "../../utils/logger.js";
5
+ /**
6
+ * Ceiling for a synchronous Ollama query (`list`, `--version`, `rm`, …).
7
+ *
8
+ * `spawnSync` blocks the event loop, so an unresponsive daemon does not make
9
+ * the CLI slow — it makes it unkillable by anything short of Ctrl-C, with no
10
+ * output and no error. These calls are local IPC that normally answer in
11
+ * milliseconds, so a ceiling this generous only ever fires on a genuine wedge.
12
+ *
13
+ * `killSignal: "SIGKILL"` is not decoration. `spawnSync`'s `timeout` sends
14
+ * SIGTERM by default and then keeps waiting, so a child that ignores SIGTERM
15
+ * hangs forever anyway and the timeout buys nothing.
16
+ *
17
+ * Long-running commands (`ollama pull`) must pass their own `timeout` — the
18
+ * spread below lets a caller override this — because a model download
19
+ * legitimately runs for many minutes.
20
+ */
21
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
22
+ /**
23
+ * Ceiling for the whole readiness loop, not one probe inside it.
24
+ *
25
+ * Ollama normally answers within seconds of starting; a wait longer than this
26
+ * means it is not coming up, and continuing to spin helps nobody. Sized to
27
+ * outlast a slow cold start while staying far below the ~15 minutes that 30
28
+ * attempts of bounded probes could otherwise reach.
29
+ */
30
+ const READINESS_DEADLINE_MS = 90_000;
5
31
  /**
6
32
  * Shared Ollama utilities for CLI commands
7
33
  */
@@ -11,11 +37,24 @@ export class OllamaUtils {
11
37
  */
12
38
  static safeSpawn(command, args, options = {}) {
13
39
  const defaultOptions = {
40
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
41
+ killSignal: "SIGKILL",
14
42
  ...options,
15
43
  encoding: "utf8", // Always enforce utf8 encoding
16
44
  };
17
45
  return spawnSync(command, args, defaultOptions);
18
46
  }
47
+ /**
48
+ * Whether a `safeSpawn` call actually succeeded.
49
+ *
50
+ * `spawnSync` reports failure by RETURNING — `error` set (ENOENT, ETIMEDOUT)
51
+ * or a non-zero `status` — never by throwing. Every `try/catch` around one of
52
+ * these calls was therefore dead code, which is why several fallbacks in this
53
+ * file had never run.
54
+ */
55
+ static spawnSucceeded(result) {
56
+ return !result.error && result.status === 0;
57
+ }
19
58
  /**
20
59
  * Check if Ollama command line is available
21
60
  */
@@ -70,7 +109,16 @@ export class OllamaUtils {
70
109
  */
71
110
  static async waitForOllamaReady(maxAttempts = 30, initialDelay = 500) {
72
111
  let delay = initialDelay;
112
+ // A per-call bound alone is not enough here, and adding one made this
113
+ // worse before it made it better: each attempt can now burn the full
114
+ // command bound plus the API bound, so 30 attempts is up to ~15 minutes of
115
+ // spinner rather than the seconds this loop was written to take. The
116
+ // per-attempt bound stops one wedged call; this stops the loop around it.
117
+ const deadline = Date.now() + READINESS_DEADLINE_MS;
73
118
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
119
+ if (Date.now() >= deadline) {
120
+ return false;
121
+ }
74
122
  try {
75
123
  // Try command line check first
76
124
  if (!this.isOllamaCommandReady()) {
@@ -117,11 +165,17 @@ export class OllamaUtils {
117
165
  try {
118
166
  if (process.platform === "darwin") {
119
167
  logger.always(chalk.gray("Starting Ollama on macOS..."));
120
- try {
121
- this.safeSpawn("open", ["-a", "Ollama"]);
168
+ // `safeSpawn` RETURNS a result; it does not throw on a failed command,
169
+ // so this catch only ever fired on a programming error and the
170
+ // `ollama serve` fallback beneath it was effectively unreachable.
171
+ // Bounding these calls made that worse: a timeout yields
172
+ // `error: ETIMEDOUT` with `status: null`, still not a throw, so a
173
+ // wedged launcher would have been reported as a successful start.
174
+ // Branch on the result so the fallback actually runs.
175
+ if (this.spawnSucceeded(this.safeSpawn("open", ["-a", "Ollama"]))) {
122
176
  logger.always(chalk.green("✅ Ollama app started"));
123
177
  }
124
- catch {
178
+ else {
125
179
  const child = spawn("ollama", ["serve"], {
126
180
  stdio: "ignore",
127
181
  detached: true,
@@ -135,11 +189,10 @@ export class OllamaUtils {
135
189
  }
136
190
  else if (process.platform === "linux") {
137
191
  logger.always(chalk.gray("Starting Ollama service on Linux..."));
138
- try {
139
- this.safeSpawn("systemctl", ["start", "ollama"]);
192
+ if (this.spawnSucceeded(this.safeSpawn("systemctl", ["start", "ollama"]))) {
140
193
  logger.always(chalk.green("✅ Ollama service started"));
141
194
  }
142
- catch {
195
+ else {
143
196
  const child = spawn("ollama", ["serve"], {
144
197
  stdio: "ignore",
145
198
  detached: true,
@@ -156,11 +209,16 @@ export class OllamaUtils {
156
209
  // Security Note: Windows shell=true usage is intentional here for 'start' command.
157
210
  // Arguments are controlled internally (no user input) and safeSpawn validates command names.
158
211
  // This is safer than alternative Windows process creation methods for this specific use case.
159
- this.safeSpawn("start", ["ollama", "serve"], {
212
+ const started = this.safeSpawn("start", ["ollama", "serve"], {
160
213
  stdio: "ignore",
161
214
  shell: true,
162
215
  });
163
- logger.always(chalk.green("✅ Ollama service started"));
216
+ if (this.spawnSucceeded(started)) {
217
+ logger.always(chalk.green("✅ Ollama service started"));
218
+ }
219
+ else {
220
+ logger.always(chalk.yellow("⚠️ Could not confirm Ollama started — check with: ollama list"));
221
+ }
164
222
  }
165
223
  // Wait for service to become ready with readiness probe
166
224
  const readinessSpinner = ora("Waiting for Ollama service to be ready...").start();
@@ -6,7 +6,7 @@
6
6
  * a local transcript regardless, so reading those covers the rest — and covers
7
7
  * history from before the proxy existed.
8
8
  */
9
- import type { LocalUsageAggregateReport, LocalUsageScanOptions } from "../types/index.js";
9
+ import type { LocalUsageAggregateOptions, LocalUsageAggregateReport } from "../types/index.js";
10
10
  export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, registerLocalUsageReader, } from "./localUsageReaderRegistry.js";
11
11
  /**
12
12
  * Scan every registered reader whose CLI is actually present on this machine.
@@ -15,4 +15,4 @@ export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsa
15
15
  * the user never installed is not an error, and collapsing the two would make
16
16
  * a broken reader indistinguishable from an absent one.
17
17
  */
18
- export declare function readAllLocalUsage(options?: LocalUsageScanOptions): Promise<LocalUsageAggregateReport>;
18
+ export declare function readAllLocalUsage(options?: LocalUsageAggregateOptions): Promise<LocalUsageAggregateReport>;
@@ -19,7 +19,14 @@ export async function readAllLocalUsage(options) {
19
19
  const totals = {};
20
20
  const failures = [];
21
21
  const notInstalled = [];
22
- for (const cliId of getRegisteredLocalUsageCliIds()) {
22
+ // Filtered BEFORE construction, not after: `only` decides which stores are
23
+ // opened at all. Reading all of them and discarding the rest cost 28s for a
24
+ // single-CLI query that needs 10.
25
+ const requested = options?.only;
26
+ const cliIds = requested
27
+ ? getRegisteredLocalUsageCliIds().filter((id) => requested.includes(id))
28
+ : getRegisteredLocalUsageCliIds();
29
+ for (const cliId of cliIds) {
23
30
  try {
24
31
  const reader = await createLocalUsageReader(cliId);
25
32
  if (!(await reader.detect())) {