@wirecat/cli-core 0.359.0 → 0.361.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,32 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.361.0 — 19.10.2026
11
+
12
+ ### Fixed
13
+
14
+ - **A warning in `serve`'s log now says which warning it was.** Off a terminal, a re-key given up at exit, a write
15
+ the store did not take, a passive listen that failed and a stored-chats call that failed each wrote a bare
16
+ `serve.warning` record, so the log could not tell them apart. The record now carries the warning's `code`,
17
+ `operation` and `counts` (`rekeys_not_applied` with `counts.rekeys`), never its sentence, which can quote an
18
+ error or a chat. Warnings that have no code yet still log a bare record.
19
+ - **The adapter test example in [ADAPTERS.md](docs/dev/ADAPTERS.md#running-the-contract-cases) no longer gives up
20
+ re-keys.** It handed `run()` `rekeyPacing: { lockMs: 0 }`, one re-key per transaction, so a few hundred re-keys
21
+ outlasted `closeWaitMs: 10` and were given up. It now hands `{ lockMs: 1000, pauseMs: 0 }` and says what each
22
+ field does.
23
+
24
+ ## 0.360.0 — 19.10.2026
25
+
26
+ ### Added
27
+
28
+ - **Adapter test authors can shorten a connection's close wait and re-key pauses.** The run environment takes
29
+ `closeWaitMs` (how long a connection's close waits on its saves and passive listens, 5 s by default) and
30
+ `rekeyPacing` (`{ lockMs, pauseMs }` for chat re-keys, 50 ms and 25 ms by default). Commands, the shared services and
31
+ the MCP server's tool calls pass them on; a command `serve` runs for a client through its relay keeps the
32
+ defaults. A test through `run()` no longer pays the real waits. Only code
33
+ that builds the environment can set them; no flag, variable or MCP call does
34
+ ([ADAPTERS.md](docs/dev/ADAPTERS.md#running-the-contract-cases)).
35
+
10
36
  ## 0.359.0 — 19.10.2026
11
37
 
12
38
  ### Fixed
@@ -35,6 +35,13 @@ export interface BaseEnvironment {
35
35
  recognizer?: OpenRecognizer;
36
36
  /** How long `store restore` and `store reset` wait on another connection's write, 1 s unless a test hands one in. */
37
37
  storeLockWaitMs?: number;
38
+ /** How long a connection's close waits on its saves and passive listens, 5 s unless a test hands one in. */
39
+ closeWaitMs?: number;
40
+ /** How long one pass of chat re-keys holds the store's write lock and the pause after it; tests hand in no pause. */
41
+ rekeyPacing?: {
42
+ lockMs: number;
43
+ pauseMs: number;
44
+ };
38
45
  /** Where raw bytes go when stdout is not this process's own — an MCP session relayed by serve. */
39
46
  stdout?: NodeJS.WritableStream;
40
47
  /** Where a prompt and what is typed at it go, in place of the process's stderr; tests hand one in. */
@@ -8,7 +8,7 @@ import { type PermissionKey } from "../../sends/permissions.js";
8
8
  import { type ServiceMessenger, type Services } from "../../services/index.js";
9
9
  import type { OpenRecognizer } from "../../speech/transcribe.js";
10
10
  import { type AccountKey, type DeletionScope, type MessageStore } from "../../store/store.js";
11
- import { type BaseContext } from "../context.js";
11
+ import { type BaseContext, type BaseEnvironment } from "../context.js";
12
12
  import { type PermissionKeyOf } from "../permission-keys.js";
13
13
  import type { GlobalFlags, ResolveOptions, Settings } from "../settings.js";
14
14
  /**
@@ -225,6 +225,8 @@ export interface MessengerContext extends BaseContext {
225
225
  name?: string;
226
226
  }) => Promise<T>;
227
227
  }
228
+ /** The waits a test shortens through its environment; a run without them keeps the defaults. */
229
+ type ConnectionWaits = Pick<BaseEnvironment, "closeWaitMs" | "rekeyPacing">;
228
230
  /**
229
231
  * A connection as every shared reader sees it: its account remembered, each call a run event, what
230
232
  * the reads answer saved to the store. `close` closes the connection and the store it opened. One
@@ -234,7 +236,7 @@ export interface MessengerContext extends BaseContext {
234
236
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
235
237
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
236
238
  */
237
- export declare const connected: (connection: MessengerAdapter, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }: Pick<Messenger, "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId">, { settings, env, renderer }: Pick<BaseContext, "settings" | "env" | "renderer">, events: EventSink, store?: LazyStore, { listen: watching }?: Pick<ConnectOptions, "listen">) => {
239
+ export declare const connected: (connection: MessengerAdapter, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }: Pick<Messenger, "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId">, { settings, env, renderer }: Pick<BaseContext, "settings" | "env" | "renderer">, events: EventSink, store?: LazyStore, { listen: watching, closeWaitMs, rekeyPacing, }?: Pick<ConnectOptions, "listen"> & ConnectionWaits) => {
238
240
  adapter: MessengerAdapter;
239
241
  close: () => Promise<void>;
240
242
  };
@@ -253,3 +255,4 @@ export declare const connectedFor: (command: Command, messenger: Pick<Messenger,
253
255
  */
254
256
  export declare const refuseLocalWrite: (context: MessengerContext, command: string, permission: PermissionKey) => void;
255
257
  export declare const messengerContext: (command: Command, messenger: Messenger, resolving?: Pick<ResolveOptions, "needsProfile">) => MessengerContext;
258
+ export {};
@@ -118,7 +118,7 @@ const settled = async (pending, ms) => {
118
118
  * never fails the command; one that fails once the connection is `closing` is the close, not news. It still listens
119
119
  * while the connection closes, so what arrives then is saved too.
120
120
  */
121
- const listened = ({ run, operation, what }, events, warn) => {
121
+ const listened = ({ run, operation, what }, events, warn, waitMs) => {
122
122
  const abort = new AbortController();
123
123
  let closing = false;
124
124
  const ended = Promise.resolve()
@@ -126,8 +126,9 @@ const listened = ({ run, operation, what }, events, warn) => {
126
126
  .catch((error) => {
127
127
  if (closing)
128
128
  return;
129
- events({ event: "warning", code: "listen_failed", operation });
130
- warn(`${what} arriving during this command are not saved: ${error instanceof Error ? error.message : String(error)}`);
129
+ const warning = { event: "warning", code: "listen_failed", operation };
130
+ events(warning);
131
+ warn(`${what} arriving during this command are not saved: ${error instanceof Error ? error.message : String(error)}`, warning);
131
132
  });
132
133
  return {
133
134
  operation,
@@ -137,7 +138,7 @@ const listened = ({ run, operation, what }, events, warn) => {
137
138
  },
138
139
  stop: () => {
139
140
  abort.abort();
140
- return settled(new Set([ended]), SAVES_WAIT_MS);
141
+ return settled(new Set([ended]), waitMs);
141
142
  },
142
143
  };
143
144
  };
@@ -171,6 +172,10 @@ const rekeyRecipients = async (app, profile, env, warn, from, to) => {
171
172
  warn(`the recipient list still names ${from}: ${error instanceof Error ? error.message : String(error)}`);
172
173
  }
173
174
  };
175
+ const waitsOf = (command) => {
176
+ const { closeWaitMs, rekeyPacing } = environmentOf(command);
177
+ return { ...(closeWaitMs === undefined ? {} : { closeWaitMs }), ...(rekeyPacing ? { rekeyPacing } : {}) };
178
+ };
174
179
  /**
175
180
  * A connection as every shared reader sees it: its account remembered, each call a run event, what
176
181
  * the reads answer saved to the store. `close` closes the connection and the store it opened. One
@@ -180,7 +185,7 @@ const rekeyRecipients = async (app, profile, env, warn, from, to) => {
180
185
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
181
186
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
182
187
  */
183
- export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false } = {}) => {
188
+ export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false, closeWaitMs = SAVES_WAIT_MS, rekeyPacing, } = {}) => {
184
189
  const self = connection.self();
185
190
  if (self !== null)
186
191
  rememberAccount(app, settings.profile, self, env);
@@ -207,12 +212,13 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
207
212
  announcing,
208
213
  closing: closing.signal,
209
214
  ...(deletedWithoutChat ? { deletedWithoutChat } : {}),
215
+ ...(rekeyPacing ? { rekeyPacing } : {}),
210
216
  rekeyed: (from, to) => rekeyRecipients(app, settings.profile, env, renderer.warn, from, to),
211
217
  recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
212
218
  ...(history === "store" ? { namesFromStore: savedChatId ? { savedChatId } : {} } : {}),
213
219
  });
214
220
  const passives = history === "store" && self !== null && !watching
215
- ? passivesOf(saving).map((passive) => listened(passive, events, renderer.warn))
221
+ ? passivesOf(saving).map((passive) => listened(passive, events, renderer.warn, closeWaitMs))
216
222
  : [];
217
223
  return {
218
224
  adapter: saving,
@@ -225,22 +231,22 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
225
231
  finally {
226
232
  try {
227
233
  const ended = await Promise.all(passives.map((passive) => passive.stop()));
234
+ const warn = renderer.warn;
235
+ const warned = (warning, text) => {
236
+ events(warning);
237
+ warn(text, warning);
238
+ };
228
239
  passives.forEach(({ operation, what }, at) => {
229
240
  if (ended[at])
230
241
  return;
231
- events({ event: "warning", code: "store_not_written", operation });
232
- renderer.warn(`not saved to the local store: the messenger was still handing on ${what} at exit`);
242
+ warned({ event: "warning", code: "store_not_written", operation }, `not saved to the local store: the messenger was still handing on ${what} at exit`);
233
243
  });
234
- await settled(new Set([...pending, ...announcing]), SAVES_WAIT_MS);
244
+ await settled(new Set([...pending, ...announcing]), closeWaitMs);
235
245
  closing.abort();
236
- if (pending.size > 0) {
237
- events({ event: "warning", code: "store_not_written", operation: "close" });
238
- renderer.warn("not saved to the local store: the last messages were still being written at exit");
239
- }
240
- if (announcing.size > 0) {
241
- events({ event: "warning", code: "stored_chats_failed", operation: "close" });
242
- renderer.warn("the messenger's stored-chats hook did not finish and was abandoned at exit; nothing is lost");
243
- }
246
+ if (pending.size > 0)
247
+ warned({ event: "warning", code: "store_not_written", operation: "close" }, "not saved to the local store: the last messages were still being written at exit");
248
+ if (announcing.size > 0)
249
+ warned({ event: "warning", code: "stored_chats_failed", operation: "close" }, "the messenger's stored-chats hook did not finish and was abandoned at exit; nothing is lost");
244
250
  }
245
251
  finally {
246
252
  await store.close();
@@ -265,7 +271,10 @@ export const connectedFor = async (command, messenger, base, options) => {
265
271
  }
266
272
  return {
267
273
  connection,
268
- ...connected(connection, messenger, base, options.events, store, options.listen ? { listen: true } : {}),
274
+ ...connected(connection, messenger, base, options.events, store, {
275
+ ...waitsOf(command),
276
+ ...(options.listen ? { listen: true } : {}),
277
+ }),
269
278
  };
270
279
  };
271
280
  const unrecorded = (command, messenger, profile, offline) => {
@@ -410,7 +419,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
410
419
  throw new CliError("validation_error", "the read connection has already been released");
411
420
  }
412
421
  base.track(connection);
413
- held = connected(connection, messenger, base, events);
422
+ held = connected(connection, messenger, base, events, undefined, waitsOf(command));
414
423
  }
415
424
  return held.adapter;
416
425
  },
@@ -13,8 +13,9 @@ export declare const noticeFor: (context: MessengerContext, app: AppIdentity, se
13
13
  /**
14
14
  * A serve left running writes its diagnostics to a log file, so off a terminal each one is a log record with
15
15
  * its time and run. A sentence handed to the renderer may quote an error or a chat, so the record keeps only
16
- * which kind of line it was; serve's own reports go through `noticeFor` with a code. The renderer is changed
17
- * in place, so the send guard, bound to it before serve started, writes records too.
16
+ * which kind of line it was, and a warning's code, operation and counts when the warning event comes with it
17
+ * (`Warn`); serve's own reports go through `noticeFor` with a code. The renderer is changed in place, so the
18
+ * send guard, bound to it before serve started, writes records too.
18
19
  */
19
20
  export declare const recordDiagnostics: (context: MessengerContext, app: AppIdentity) => MessengerContext;
20
21
  export {};
@@ -19,16 +19,18 @@ export const noticeFor = (context, app, severity = "warn") => ({ text, ...attrib
19
19
  /**
20
20
  * A serve left running writes its diagnostics to a log file, so off a terminal each one is a log record with
21
21
  * its time and run. A sentence handed to the renderer may quote an error or a chat, so the record keeps only
22
- * which kind of line it was; serve's own reports go through `noticeFor` with a code. The renderer is changed
23
- * in place, so the send guard, bound to it before serve started, writes records too.
22
+ * which kind of line it was, and a warning's code, operation and counts when the warning event comes with it
23
+ * (`Warn`); serve's own reports go through `noticeFor` with a code. The renderer is changed in place, so the
24
+ * send guard, bound to it before serve started, writes records too.
24
25
  */
25
26
  export const recordDiagnostics = (context, app) => {
26
27
  if (context.format === "pretty")
27
28
  return context;
28
- const say = (severity, event, always = false) => (_text) => {
29
+ const say = (severity, event, always = false) => (_text, warning) => {
29
30
  if (context.settings.quiet && !always)
30
31
  return;
31
- record(context, app, severity, { event });
32
+ const { event: _warning, ...fields } = warning ?? {};
33
+ record(context, app, severity, { event, ...fields });
32
34
  };
33
35
  const renderer = context.renderer;
34
36
  renderer.note = say("info", "serve.note");
@@ -1,13 +1,13 @@
1
1
  import type { Id } from "../../domain/models.js";
2
2
  import { type MessengerAdapter } from "../../ports/messenger.js";
3
3
  import type { MessengerTraits } from "../../ports/traits.js";
4
- import type { EventSink } from "../../runs/events.js";
4
+ import type { EventSink, Warn } from "../../runs/events.js";
5
5
  import type { AccountKey, DeletionScope, MessageStore } from "../../store/store.js";
6
6
  export interface Saving {
7
7
  account: AccountKey;
8
8
  /** Opened on the first save, not before: a command that reads nothing has no reason to touch the file. */
9
9
  store: () => Promise<MessageStore | undefined>;
10
- warn: (message: string) => void;
10
+ warn: Warn;
11
11
  events: EventSink;
12
12
  /** Saves still writing, for the closer to wait on: `watch` does not hold the next event for one. */
13
13
  pending?: Set<Promise<void>>;
@@ -16,9 +16,10 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
16
16
  let warned = false;
17
17
  let recorded = profile === undefined;
18
18
  const notWritten = (operation, error) => {
19
- events({ event: "warning", code: "store_not_written", operation });
19
+ const warning = { event: "warning", code: "store_not_written", operation };
20
+ events(warning);
20
21
  if (!warned)
21
- warn(`not saved to the local store: ${error instanceof Error ? error.message : String(error)}`);
22
+ warn(`not saved to the local store: ${error instanceof Error ? error.message : String(error)}`, warning);
22
23
  warned = true;
23
24
  };
24
25
  const attempt = async (operation, write) => {
@@ -92,8 +93,14 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
92
93
  pending?.delete(saving);
93
94
  if (count === 0)
94
95
  return;
95
- events({ event: "warning", code: "rekeys_not_applied", operation: "history.rekey", counts: { rekeys: count } });
96
- warn(`${count} chat re-key${count === 1 ? "" : "s"} not applied yet; the next serve applies them`);
96
+ const warning = {
97
+ event: "warning",
98
+ code: "rekeys_not_applied",
99
+ operation: "history.rekey",
100
+ counts: { rekeys: count },
101
+ };
102
+ events(warning);
103
+ warn(`${count} chat re-key${count === 1 ? "" : "s"} not applied yet; the next serve applies them`, warning);
97
104
  };
98
105
  closing?.addEventListener("abort", giveUpRekeys, { once: true });
99
106
  const saveRekeys = async (pairs, round) => {
@@ -466,8 +473,9 @@ const announceStoredChats = async (messenger, store, account, events, warn, reci
466
473
  await messenger.storedChats?.(held);
467
474
  }
468
475
  catch (error) {
469
- events({ event: "warning", code: "stored_chats_failed", operation: "history.stored_chats" });
470
- warn(`the messenger was not told the stored chats: ${error instanceof Error ? error.message : String(error)}`);
476
+ const warning = { event: "warning", code: "stored_chats_failed", operation: "history.stored_chats" };
477
+ events(warning);
478
+ warn(`the messenger was not told the stored chats: ${error instanceof Error ? error.message : String(error)}`, warning);
471
479
  }
472
480
  };
473
481
  /** Read once when `watch` starts: a store read per event would let a later event overtake an earlier one. */
@@ -49,6 +49,8 @@ export interface CacheEvent {
49
49
  }
50
50
  export type DiagnosticEvent = RequestEvent | CacheEvent | WarningEvent;
51
51
  export type EventSink = (event: DiagnosticEvent) => void;
52
+ /** A sentence for a person, and the warning event it stands for when there is one: a log record keeps only the event. */
53
+ export type Warn = (message: string, warning?: WarningEvent) => void;
52
54
  /** `FLOOD_WAIT`, `PEER_ID_INVALID`, `login.token` — or nothing. A sentence is dropped, not trimmed. */
53
55
  export declare const providerErrorKey: (value: unknown) => string | undefined;
54
56
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.359.0",
3
+ "version": "0.361.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",