@wirecat/cli-core 0.493.0 → 0.495.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.495.0 — 31.10.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **`gatherProjectEvidence` says what the store holds of each linked chat, and which ones the run never reached.** A new
15
+ optional port `coverage(account, chatIds)` (pass `chatCoverage` over the store) fills each `linkedChats` row with
16
+ `title` (the chat row's, also for a chat with no stored message), `completeness` and `held`; without it they are `null` and `completeness` stays `"unknown"`. The top-level
17
+ `completeness` is `partial` when any linked chat is `partial`, `unknown` when none is but one is `unknown` (a chat the
18
+ store has no row for counts), and `complete` only when every linked chat in scope is. `complete` keeps its meaning,
19
+ "the planned reads finished"; a host's `meta.complete` is `complete && completeness === "complete"`. A linked chat
20
+ none of whose reads has started is `state: "pending"` and has no `items` or `capped`, where it said `in-scope` with
21
+ `items: 0, capped: false`. `ChatCoverage.title` now comes from the chat row when the chat has no message, through the
22
+ new `store.chats.chatTitle`.
23
+
24
+ ## 0.494.0 — 31.10.2026
25
+
26
+ ### Security
27
+
28
+ - **A run with moved folders no longer copies the owner's sign-in into the keyring when it refreshes a token.** Since
29
+ cli-base 0.65.5 such a run (`<PREFIX>_*_DIR` overrides, or `HOME` or XDG folders away from the account's home) writes
30
+ to a temporary entry `<app>:<config folder>`, so every refresh of an expired Google token left a full copy of the
31
+ owner's sign-in there until `session end`. `tokenKeeper` now keeps such a refresh in memory for the rest of the
32
+ process and saves nothing; a new sign-in, a token saved by the CLI's own flow, and a refresh that rotated the
33
+ refresh token are still saved. `credentialStorage` also no longer copies an older account's entry to the new
34
+ account in such a run. New optional `TokenStorage.moved`.
35
+
10
36
  ## 0.493.0 — 31.10.2026
11
37
 
12
38
  ### Added
@@ -6,6 +6,7 @@ import { acquireLock } from "../files/index.js";
6
6
  import { authorize } from "./authorize.js";
7
7
  import { requestTokens, revokeToken, TOKEN_LOG_EVENT } from "./requests.js";
8
8
  const inFlight = new Map();
9
+ const refreshedInMemory = new Map();
9
10
  export const tokenKeeper = (options) => {
10
11
  const { provider, storage, signal, log } = options;
11
12
  const now = options.now ?? Date.now;
@@ -81,12 +82,20 @@ export const tokenKeeper = (options) => {
81
82
  inFlight.set(lockPath, started);
82
83
  return started;
83
84
  };
85
+ const remembered = (saved) => {
86
+ const kept = refreshedInMemory.get(lockPath);
87
+ return kept && fresh(kept) && kept.refreshToken === saved.refreshToken ? kept : undefined;
88
+ };
89
+ const forget = () => refreshedInMemory.delete(lockPath);
84
90
  const tokens = async () => {
85
91
  const seen = storage.read();
86
92
  if (!seen)
87
93
  throw notSignedIn();
88
94
  if (fresh(seen.tokens))
89
95
  return seen.tokens;
96
+ const kept = remembered(seen.tokens);
97
+ if (kept)
98
+ return kept;
90
99
  return shared(async () => {
91
100
  const current = storage.read();
92
101
  if (!current)
@@ -94,9 +103,16 @@ export const tokenKeeper = (options) => {
94
103
  // Another process refreshed while this one waited for the lock: its result is the one to use.
95
104
  if (fresh(current.tokens) || current.tokens.refreshToken !== seen.tokens.refreshToken)
96
105
  return current.tokens;
106
+ const keptMeanwhile = remembered(current.tokens);
107
+ if (keptMeanwhile)
108
+ return keptMeanwhile;
97
109
  const client = clientOf(current);
98
110
  const refreshed = await exchange({ grant_type: "refresh_token", refresh_token: current.tokens.refreshToken }, context(client, current.tokens));
99
- storage.write({ tokens: refreshed, client });
111
+ // A rotated refresh token is saved even so: kept only in memory, it would die with the process.
112
+ if (storage.moved && refreshed.refreshToken === current.tokens.refreshToken)
113
+ refreshedInMemory.set(lockPath, refreshed);
114
+ else
115
+ storage.write({ tokens: refreshed, client });
100
116
  return refreshed;
101
117
  });
102
118
  };
@@ -119,11 +135,18 @@ export const tokenKeeper = (options) => {
119
135
  code_verifier: authorization.codeVerifier,
120
136
  }, context(client));
121
137
  const extra = (await accept?.(received)) ?? undefined;
122
- await locked(async () => storage.write({ tokens: received, client, ...(extra ? { extra } : {}) }));
138
+ await locked(async () => {
139
+ forget();
140
+ storage.write({ tokens: received, client, ...(extra ? { extra } : {}) });
141
+ });
123
142
  return { scopes: received.scopes };
124
143
  },
125
- save: (saved, extra) => locked(async () => storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) })),
144
+ save: (saved, extra) => locked(async () => {
145
+ forget();
146
+ storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) });
147
+ }),
126
148
  signOut: ({ revoke = false } = {}) => locked(async () => {
149
+ forget();
127
150
  const current = storage.read();
128
151
  let revoked = false;
129
152
  if (revoke && current) {
@@ -21,6 +21,11 @@ export interface TokenCodec {
21
21
  export interface TokenStorage {
22
22
  /** Names the entry across processes; the lock is keyed by it. */
23
23
  readonly key: string;
24
+ /**
25
+ * A run with moved folders: cli-base sends its writes to a temporary keyring entry, so a refresh is kept in memory
26
+ * instead of copying the owner's sign-in there. Only a new sign-in is saved.
27
+ */
28
+ readonly moved?: boolean;
24
29
  /** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
25
30
  probe(): void;
26
31
  read(): StoredTokens | undefined;
@@ -1,4 +1,4 @@
1
- import { CliError, Credentials, keyringService, } from "@wirecat/cli-base";
1
+ import { CliError, Credentials, isMovedRun, keyringService, systemKeyring, } from "@wirecat/cli-base";
2
2
  const invalid = () => new CliError("authentication_error", "the saved sign-in cannot be read; sign in again", { reason: "oauth.invalid" });
3
3
  const parse = (raw) => {
4
4
  let value;
@@ -149,6 +149,10 @@ export const credentialStorage = (options) => {
149
149
  throw new CliError("configuration_error", "a sign-in store needs its keyring passed in, never a default");
150
150
  const credentials = new Credentials(options.credentials);
151
151
  const { account, codec } = options;
152
+ // cli-base also counts a moved HOME as moved, but only with the system keyring; the sandboxed tests move HOME.
153
+ const moved = options.credentials.isolated === true ||
154
+ (options.credentials.keyring === systemKeyring &&
155
+ isMovedRun({ appName: options.credentials.service, env: options.credentials.env ?? process.env }));
152
156
  const service = keyringService(options.credentials.service, options.credentials.configDir, options.credentials.isolated);
153
157
  const raw = (name) => {
154
158
  try {
@@ -175,13 +179,15 @@ export const credentialStorage = (options) => {
175
179
  if (found === undefined)
176
180
  continue;
177
181
  codec.decode(found);
178
- save(found);
182
+ if (!moved)
183
+ save(found);
179
184
  return found;
180
185
  }
181
186
  return undefined;
182
187
  };
183
188
  return {
184
189
  key: `${service}\u0000${account}`,
190
+ moved,
185
191
  probe: () => {
186
192
  raw(account);
187
193
  },
@@ -3,7 +3,7 @@ import type { AccountKey, ChatCompleteness, MessageStore } from "../store/store.
3
3
  /** What the store holds of one chat, and how far that goes: never message text. */
4
4
  export interface ChatCoverage {
5
5
  chatId: Id;
6
- /** `null` while the store holds no message of the chat: titles come with the stats. */
6
+ /** The chat row's title; `null` when the store has no row for the chat or the row has none. */
7
7
  title: string | null;
8
8
  completeness: ChatCompleteness;
9
9
  held: {
@@ -9,6 +9,7 @@ export const chatCoverage = async (store, account, chatIds) => {
9
9
  const found = new Map((await store.chats.chatCompleteness(account, ids)).map((one) => [one.chatId, one]));
10
10
  return Promise.all(ids.map(async (chatId) => {
11
11
  const [stats] = await store.chats.chatStats(account, chatId);
12
+ const title = stats?.title ?? (await store.chats.chatTitle(account, chatId));
12
13
  const completeness = found.get(chatId) ?? {
13
14
  chatId,
14
15
  state: "unknown",
@@ -19,7 +20,7 @@ export const chatCoverage = async (store, account, chatIds) => {
19
20
  };
20
21
  return {
21
22
  chatId,
22
- title: stats?.title ?? null,
23
+ title,
23
24
  completeness,
24
25
  held: { messages: stats?.messages ?? 0, oldestAt: stats?.oldestAt ?? null, newestAt: stats?.newestAt ?? null },
25
26
  };
@@ -1,4 +1,6 @@
1
1
  import type { ContextEvidenceItem, ContextEvidenceReader, ContextIndex, ContextSource } from "../context/evidence.js";
2
+ import type { AccountKey } from "../store/types.js";
3
+ import type { ChatCoverage } from "./chat-coverage.js";
2
4
  import { type ProjectNeighbour, type ProjectNeighbourReader } from "./project-gathering-around.js";
3
5
  import { type ProjectFetchState, type ProjectGatheringRequest, type ProjectLane } from "./project-gathering-request.js";
4
6
  /** A connector's history fetch into the store; the store reads that follow find what it saved. */
@@ -18,6 +20,8 @@ export interface ProjectGatheringPorts {
18
20
  fetcher?: ProjectSourceFetcher;
19
21
  index?: ContextIndex;
20
22
  neighbours?: ProjectNeighbourReader;
23
+ /** What the store holds of the named chats of one account, e.g. `chatCoverage` over the store. */
24
+ coverage?: (account: AccountKey, chatIds: string[]) => Promise<ChatCoverage[]>;
21
25
  now?: () => number;
22
26
  }
23
27
  export interface ProjectIndexCatchUp {
@@ -88,9 +92,16 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
88
92
  ref: string;
89
93
  source: number | null;
90
94
  lane: string | null;
91
- state: "in-scope" | "out-of-scope" | "unavailable";
92
- items: number;
93
- capped: boolean;
95
+ state: "in-scope" | "out-of-scope" | "pending" | "unavailable";
96
+ title: string | null;
97
+ completeness: import("../store/exports.js").ChatCompleteness | null;
98
+ held: {
99
+ messages: number;
100
+ oldestAt: string | null;
101
+ newestAt: string | null;
102
+ } | null;
103
+ items?: number | undefined;
104
+ capped?: boolean | undefined;
94
105
  }[];
95
106
  fetch: {
96
107
  state: ProjectFetchState;
@@ -136,7 +147,7 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
136
147
  };
137
148
  stopped: ProjectGatheringStop;
138
149
  complete: boolean;
139
- completeness: "unknown";
150
+ completeness: "complete" | "partial" | "unknown";
140
151
  checkpoint: string | null;
141
152
  }>;
142
153
  export type ProjectGathering = Awaited<ReturnType<typeof gatherProjectEvidence>>;
@@ -264,6 +264,20 @@ export const gatherProjectEvidence = async (ports, request) => {
264
264
  },
265
265
  }, (ref) => byRef.has(ref) || seen.has(seenKey(ref)), signal);
266
266
  const people = peopleOf(items, senderOf);
267
+ const coverageOf = new Map();
268
+ if (ports.coverage) {
269
+ const byAccount = new Map();
270
+ for (const { provider, account, chat, source } of plan.linkedChats) {
271
+ if (source === null)
272
+ continue;
273
+ const group = byAccount.get(JSON.stringify([provider, account])) ?? { account: { provider, account }, chats: [] };
274
+ group.chats.push(chat);
275
+ byAccount.set(JSON.stringify([provider, account]), group);
276
+ }
277
+ for (const [key, { account, chats }] of byAccount)
278
+ for (const row of await ports.coverage(account, chats))
279
+ coverageOf.set(`${key}${row.chatId}`, row);
280
+ }
267
281
  const totals = {
268
282
  reads: (previous?.totals.reads ?? 0) + usage.reads,
269
283
  readBytes: (previous?.totals.readBytes ?? 0) + usage.readBytes,
@@ -284,6 +298,39 @@ export const gatherProjectEvidence = async (ports, request) => {
284
298
  seen: [...seen],
285
299
  totals,
286
300
  };
301
+ const linkedRows = plan.linkedChats.map(({ provider, account, chat, ref, source, lane }) => {
302
+ const laneUnits = units.flatMap((unit, index) => (unit.lane.id === lane ? [progress[index]] : []));
303
+ const state = source === null
304
+ ? "out-of-scope"
305
+ : laneUnits.every((u) => u?.state === "unavailable")
306
+ ? "unavailable"
307
+ : laneUnits.every((u) => u?.state === "pending")
308
+ ? "pending"
309
+ : "in-scope";
310
+ const row = coverageOf.get(`${JSON.stringify([provider, account])}${chat}`);
311
+ return {
312
+ provider,
313
+ account,
314
+ chat,
315
+ ref,
316
+ source,
317
+ lane,
318
+ state,
319
+ title: row?.title ?? null,
320
+ completeness: row?.completeness ?? null,
321
+ held: row?.held ?? null,
322
+ ...(state === "pending" ? {} : { items: lane === null ? 0 : (linkedTaken.get(lane) ?? 0) }),
323
+ ...(state === "pending" ? {} : { capped: lane !== null && capped.has(lane) }),
324
+ };
325
+ });
326
+ const states = linkedRows.flatMap((row) => (row.completeness ? [row.completeness.state] : []));
327
+ const completeness = !states.length
328
+ ? "unknown"
329
+ : states.includes("partial")
330
+ ? "partial"
331
+ : states.includes("unknown") || states.length < linkedRows.filter((row) => row.source !== null).length
332
+ ? "unknown"
333
+ : "complete";
287
334
  return {
288
335
  schemaVersion: 1,
289
336
  runKey: plan.runKey,
@@ -300,21 +347,7 @@ export const gatherProjectEvidence = async (ports, request) => {
300
347
  },
301
348
  scope: { origin: plan.scope, sources: plan.sources },
302
349
  lanes: plan.lanes,
303
- linkedChats: plan.linkedChats.map(({ provider, account, chat, ref, source, lane }) => ({
304
- provider,
305
- account,
306
- chat,
307
- ref,
308
- source,
309
- lane,
310
- state: source === null
311
- ? "out-of-scope"
312
- : units.every((unit, index) => unit.lane.id !== lane || progress[index]?.state === "unavailable")
313
- ? "unavailable"
314
- : "in-scope",
315
- items: lane === null ? 0 : (linkedTaken.get(lane) ?? 0),
316
- capped: lane !== null && capped.has(lane),
317
- })),
350
+ linkedChats: linkedRows,
318
351
  fetch: fetched.map((f, index) => ({ source: index, ...f })),
319
352
  index: indexed,
320
353
  units: units.map((unit, index) => ({
@@ -335,7 +368,7 @@ export const gatherProjectEvidence = async (ports, request) => {
335
368
  totals,
336
369
  stopped,
337
370
  complete: !resumable,
338
- completeness: "unknown",
371
+ completeness,
339
372
  checkpoint: resumable ? encodeCheckpoint(checkpoint) : null,
340
373
  };
341
374
  };
@@ -66,6 +66,8 @@ export interface ChatsStore {
66
66
  renamedChatId(key: AccountKey, chatId: Id): Promise<Id | undefined>;
67
67
  /** A stored chat's kind, with or without messages; `undefined` when the store does not know the chat. */
68
68
  chatKind(key: AccountKey, chatId: Id): Promise<ChatKind | undefined>;
69
+ /** A stored chat's title, with or without messages; `null` when it has none or the store does not know the chat. */
70
+ chatTitle(key: AccountKey, chatId: Id): Promise<string | null>;
69
71
  /** Per chat that spoke since `since` (ms): its newest message and who sent it when stored, never the text. */
70
72
  /** `where` is a `tasks agenda` query compiled over `AGENDA_COLUMNS`. */
71
73
  chatActivity(key: AccountKey, since: number, where?: Where): Promise<{
@@ -94,6 +94,15 @@ export const chatsStoreOver = (context) => {
94
94
  .get(accountKey, chatId);
95
95
  return row ? String(row.kind) : undefined;
96
96
  },
97
+ chatTitle: async (key, chatId) => {
98
+ const accountKey = findAccountPk(key);
99
+ if (accountKey === undefined)
100
+ return null;
101
+ const row = context.database
102
+ .prepare("SELECT title FROM chats WHERE account_id = ? AND external_id = ?")
103
+ .get(accountKey, chatId);
104
+ return row?.title == null ? null : String(row.title);
105
+ },
97
106
  chatActivity: async (key, since, where) => {
98
107
  const accountKey = findAccountPk(key);
99
108
  return accountKey === undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.493.0",
3
+ "version": "0.495.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",
@@ -247,7 +247,7 @@
247
247
  "@types/node": "^22.20.5",
248
248
  "@types/pngjs": "6.0.5",
249
249
  "@vitest/coverage-v8": "^5.0.5",
250
- "@wirecat/cli-base": "0.65.2",
250
+ "@wirecat/cli-base": "0.65.5",
251
251
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
252
252
  "commander": "15.0.0",
253
253
  "cspell": "10.4.0",