@wirecat/cli-core 0.572.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,25 @@ 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
+
20
+ ## 0.573.0 — 05.11.2026
21
+
22
+ ### Added
23
+
24
+ - **The owner sets a confirmed payment's wrong split right.** `store.finance.unallocate` now takes an allocation back
25
+ from a confirmed payment too, when `by` is the owner; `allocate` then puts the right amounts on, under the same
26
+ over-payment, over-invoice and currency rules, and the invoices' statuses follow. A bot is still refused on a
27
+ confirmed payment (`finance.owner-only`); a rejected or deleted payment is `finance.unallocate`.
28
+
10
29
  ## 0.572.0 — 05.11.2026
11
30
 
12
31
  ### Changed — may break callers
@@ -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)) {
@@ -181,7 +181,7 @@ const OTHERS = [
181
181
  validation("finance.ref", "the reference names no invoice or payment"),
182
182
  validation("finance.status", "the invoice's status does not allow this"),
183
183
  validation("finance.total", "the invoice's total is not the sum of its lines"),
184
- validation("finance.unallocate", "only a proposed payment's allocations are taken back"),
184
+ validation("finance.unallocate", "a rejected or deleted payment's allocations are not taken back"),
185
185
  validation("record.entity", "the invoice or payment the record stands for was refused"),
186
186
  ["record.map-owner-only", "permission_error", "only the owner says which entity a source record is"],
187
187
  validation("record.map-type", "a record maps only to an entity of its own type"),
@@ -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,
@@ -181,8 +181,8 @@ export interface FinanceStore {
181
181
  by: Author;
182
182
  }): Promise<Allocation>;
183
183
  /**
184
- * Takes one allocation back from a payment still proposed, under the same rule as `allocate`. A confirmed payment's
185
- * allocations are decided money and stay.
184
+ * Takes one allocation back, under the same rule as `allocate`: the owner from any payment, confirmed too, to set a
185
+ * wrong split right; a bot only from one it proposed that is still proposed.
186
186
  */
187
187
  unallocate(input: {
188
188
  payment: string;
@@ -501,8 +501,8 @@ export const financeOperationsOver = (context) => {
501
501
  const row = rowOf(payment, "payment");
502
502
  const ref = `payment:${row.id}`;
503
503
  mayAllocate(row, by);
504
- if (row.review !== "proposed" || row.deleted_at != null)
505
- refuse(`${ref} is ${row.deleted_at != null ? "deleted" : String(row.review)}; only a proposed payment's ` +
504
+ if (row.review === "rejected" || row.deleted_at != null)
505
+ refuse(`${ref} is ${row.deleted_at != null ? "deleted" : "rejected"}; only a proposed or confirmed payment's ` +
506
506
  "allocations are taken back", "finance.unallocate");
507
507
  const target = rowOf(invoice, "invoice");
508
508
  write(database, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.572.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",