@wirecat/cli-core 0.573.0 → 0.574.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 CHANGED
@@ -7,6 +7,16 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.574.0 — 05.11.2026
11
+
12
+ ### Fixed
13
+
14
+ - **A finished request no longer keeps the command in memory until its time limit ends.** The limit was a timer that
15
+ ran to the end even after the answer came, and it held everything the command had loaded; tg-cli and max-cli
16
+ already fixed the same thing in their own requests, and tg ran out of memory on macOS before that. This covers the
17
+ OAuth token and revoke requests, the OpenAI model request, the bot-registry checks, calendar feeds and serve's
18
+ catch-up on start. `timeLimit` and `withTimeLimit` (`src/http/time-limit.ts`) are the stoppable limit now.
19
+
10
20
  ## 0.573.0 — 05.11.2026
11
21
 
12
22
  ### Added
@@ -1,3 +1,4 @@
1
+ import { withTimeLimit } from "../http/time-limit.js";
1
2
  /** Where each one is documented, for the help text and the MCP description. */
2
3
  export const REGISTRIES = {
3
4
  cas: { title: "Combot Anti-Spam (CAS)", docs: "https://cas.chat/api" },
@@ -12,17 +13,19 @@ const TIMEOUT_MS = 10_000;
12
13
  export const askRegistries = async (id, options = {}) => Promise.all([askCas(id, options), askLols(id, options)]);
13
14
  const getJson = async (url, headers, { fetch: fetchFn = fetch, timeoutMs = TIMEOUT_MS }) => {
14
15
  try {
15
- const response = await fetchFn(url, {
16
- headers: { accept: "application/json", ...headers },
17
- signal: AbortSignal.timeout(timeoutMs),
18
- redirect: "error",
16
+ const { status, text } = await withTimeLimit(timeoutMs, async (signal) => {
17
+ const response = await fetchFn(url, {
18
+ headers: { accept: "application/json", ...headers },
19
+ signal,
20
+ redirect: "error",
21
+ });
22
+ return { status: response.status, text: await response.text() };
19
23
  });
20
- const text = await response.text();
21
24
  try {
22
- return { status: response.status, body: JSON.parse(text) };
25
+ return { status, body: JSON.parse(text) };
23
26
  }
24
27
  catch {
25
- return { failed: `answered ${response.status} with something that is not JSON` };
28
+ return { failed: `answered ${status} with something that is not JSON` };
26
29
  }
27
30
  }
28
31
  catch (error) {
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { CliError, isCliError } from "@wirecat/cli-base";
3
3
  import { zonedMoment } from "@wirecat/cli-base/query";
4
4
  import { runBudget } from "../background/budget.js";
5
+ import { timeLimit } from "../http/time-limit.js";
5
6
  import { calendarText, joinUrlWithoutQuery } from "../meetings/index.js";
6
7
  import { expandSeries, unexpandable } from "./expand.js";
7
8
  import { readIcs } from "./ics.js";
@@ -72,8 +73,8 @@ export const fetchFeedIfChanged = async (address, options) => {
72
73
  ...(since?.lastModified ? { "if-modified-since": since.lastModified } : {}),
73
74
  };
74
75
  const run = options.fetch ?? fetch;
75
- const timeout = AbortSignal.timeout(limits.timeoutMs);
76
- const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
76
+ const timeout = timeLimit(limits.timeoutMs);
77
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal;
77
78
  let url = feedAddress(address);
78
79
  try {
79
80
  for (let hop = 0;; hop++) {
@@ -122,7 +123,7 @@ export const fetchFeedIfChanged = async (address, options) => {
122
123
  throw error;
123
124
  if (options.signal?.aborted)
124
125
  throw new CliError("cancelled", `reading ${name} was cancelled`);
125
- if (timeout.aborted)
126
+ if (timeout.signal.aborted)
126
127
  throw new CliError("timeout", `${name} did not answer within ${Math.round(limits.timeoutMs / 1000)} s`, {
127
128
  reason: "feed.timeout",
128
129
  retryable: true,
@@ -134,6 +135,9 @@ export const fetchFeedIfChanged = async (address, options) => {
134
135
  retryable: true,
135
136
  });
136
137
  }
138
+ finally {
139
+ timeout.clear();
140
+ }
137
141
  };
138
142
  const SPAN = { start: 0, end: 0 };
139
143
  const firstLink = (text) => text?.match(/https?:\/\/[^\s<>"')\]]+/iu)?.[0];
@@ -1,4 +1,5 @@
1
1
  import { failureKey } from "../../errors/failures.js";
2
+ import { withTimeLimit } from "../../http/time-limit.js";
2
3
  /** The provider's own catch-up lands first; this pass then finds most chats already current. */
3
4
  export const START_SYNC_DELAY_MS = 30_000;
4
5
  export const START_SYNC_BUDGET_MS = 5 * 60_000;
@@ -19,8 +20,8 @@ export const startSync = ({ chats, newestHeld, fetch, note, warn, delayMs = STAR
19
20
  let pass = Promise.resolve();
20
21
  let started = false;
21
22
  let done;
22
- const run = async () => {
23
- const stop = AbortSignal.any([controller.signal, AbortSignal.timeout(budgetMs)]);
23
+ const run = () => withTimeLimit(budgetMs, (deadline) => catchUp(AbortSignal.any([controller.signal, deadline])));
24
+ const catchUp = async (stop) => {
24
25
  const floor = now() - windowMs;
25
26
  const moved = [];
26
27
  for (const chat of await chats(stop)) {
@@ -0,0 +1,11 @@
1
+ export interface TimeLimit {
2
+ signal: AbortSignal;
3
+ clear: () => void;
4
+ }
5
+ /**
6
+ * A time limit that can be stopped. `AbortSignal.timeout` cannot: its timer runs to the deadline even after the
7
+ * request is done, and keeps the run's whole async context (everything the command loaded) alive until then.
8
+ */
9
+ export declare const timeLimit: (ms: number) => TimeLimit;
10
+ /** Runs `run` under a time limit and stops the timer when it settles. Whatever `run` reads from the response belongs inside. */
11
+ export declare const withTimeLimit: <T>(ms: number, run: (deadline: AbortSignal) => Promise<T>) => Promise<T>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A time limit that can be stopped. `AbortSignal.timeout` cannot: its timer runs to the deadline even after the
3
+ * request is done, and keeps the run's whole async context (everything the command loaded) alive until then.
4
+ */
5
+ export const timeLimit = (ms) => {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), ms);
8
+ timer.unref();
9
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
10
+ };
11
+ /** Runs `run` under a time limit and stops the timer when it settles. Whatever `run` reads from the response belongs inside. */
12
+ export const withTimeLimit = async (ms, run) => {
13
+ const limit = timeLimit(ms);
14
+ try {
15
+ return await run(limit.signal);
16
+ }
17
+ finally {
18
+ limit.clear();
19
+ }
20
+ };
@@ -1,4 +1,5 @@
1
1
  import * as v from "valibot";
2
+ import { withTimeLimit } from "../http/time-limit.js";
2
3
  import { checkedOptions, range } from "./options.js";
3
4
  import { messagesFor, systemFor } from "./types.js";
4
5
  const options = v.strictObject({
@@ -14,7 +15,7 @@ export const openai = {
14
15
  images: true,
15
16
  baseUrl: "https://api.openai.com/v1",
16
17
  validate: (given) => checkedOptions("openai", options, given),
17
- complete: async (target, request, checked, apiKey, fetcher) => {
18
+ complete: (target, request, checked, apiKey, fetcher) => withTimeLimit(120_000, async (deadline) => {
18
19
  const response = await fetcher(`${target.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
19
20
  method: "POST",
20
21
  headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
@@ -40,7 +41,7 @@ export const openai = {
40
41
  ? { max_completion_tokens: request.maxTokens }
41
42
  : { max_tokens: request.maxTokens }),
42
43
  }),
43
- signal: AbortSignal.timeout(120_000),
44
+ signal: deadline,
44
45
  redirect: "error",
45
46
  });
46
47
  if (!response.ok)
@@ -53,5 +54,5 @@ export const openai = {
53
54
  (body.usage?.total_tokens ?? 0) < 1)
54
55
  throw new Error("incomplete response");
55
56
  return { text: choice.message.content, tokens: body.usage?.total_tokens };
56
- },
57
+ }),
57
58
  };
@@ -1,4 +1,5 @@
1
1
  import { CliError, isCliError } from "@wirecat/cli-base";
2
+ import { withTimeLimit } from "../http/time-limit.js";
2
3
  const REQUEST_LIMIT_MS = 30_000;
3
4
  export const TOKEN_LOG_EVENT = "oauth.token";
4
5
  /** The keys the module's log records carry, for a run log's `allow`; names only, never a token. */
@@ -8,20 +9,20 @@ const object = (value) => {
8
9
  throw new Error("not an object");
9
10
  return value;
10
11
  };
11
- const requestSignal = (signal) => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_LIMIT_MS)]) : AbortSignal.timeout(REQUEST_LIMIT_MS);
12
+ const requestSignal = (deadline, signal) => signal ? AbortSignal.any([signal, deadline]) : deadline;
12
13
  const cancelledBy = (signal) => {
13
14
  if (!signal?.aborted)
14
15
  return;
15
16
  throw isCliError(signal.reason) ? signal.reason : new CliError("cancelled", "sign-in was cancelled");
16
17
  };
17
- const post = async (url, body, context) => {
18
+ const post = async (url, body, context, deadline) => {
18
19
  cancelledBy(context.signal);
19
20
  try {
20
21
  return await context.fetch(url, {
21
22
  method: "POST",
22
23
  headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
23
24
  body,
24
- signal: requestSignal(context.signal),
25
+ signal: requestSignal(deadline, context.signal),
25
26
  redirect: "error",
26
27
  });
27
28
  }
@@ -38,11 +39,12 @@ const clientFields = ({ client }) => ({
38
39
  * The token endpoint, form-encoded, for both grants. Nothing from the request or the response reaches a message or
39
40
  * a log record: both carry codes and tokens.
40
41
  */
41
- export const requestTokens = async (provider, params, context) => {
42
+ export const requestTokens = (provider, params, context) => withTimeLimit(REQUEST_LIMIT_MS, (deadline) => exchangeTokens(provider, params, context, deadline));
43
+ const exchangeTokens = async (provider, params, context, deadline) => {
42
44
  const operation = params.grant_type ?? "unknown";
43
45
  const record = (level, fields) => context.log?.[level]({ event: TOKEN_LOG_EVENT, provider: provider.id, operation, pid: process.pid, ...fields });
44
46
  const again = `run ${context.signInCommand} again`;
45
- const response = await post(provider.tokenUrl, new URLSearchParams({ ...params, ...clientFields(context) }), context);
47
+ const response = await post(provider.tokenUrl, new URLSearchParams({ ...params, ...clientFields(context) }), context, deadline);
46
48
  if (!response) {
47
49
  record("warn", { outcome: "no_response" });
48
50
  throw new CliError("outcome_unknown", `${provider.name} token request did not finish; ${again} if it keeps failing`, {
@@ -112,7 +114,9 @@ export const revokeToken = async (provider, tokens, context) => {
112
114
  return provider.revoke(tokens, context);
113
115
  if (!provider.revokeUrl)
114
116
  throw new CliError("validation_error", `${provider.name} declares no way to revoke a sign-in`);
115
- const response = await post(provider.revokeUrl, new URLSearchParams({ token: tokens.refreshToken, ...clientFields(context) }), context);
117
+ const { revokeUrl } = provider;
118
+ const form = new URLSearchParams({ token: tokens.refreshToken, ...clientFields(context) });
119
+ const response = await withTimeLimit(REQUEST_LIMIT_MS, (deadline) => post(revokeUrl, form, context, deadline));
116
120
  context.log?.[response?.ok ? "info" : "warn"]({
117
121
  event: TOKEN_LOG_EVENT,
118
122
  provider: provider.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.573.0",
3
+ "version": "0.574.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",