@wirecat/cli-core 0.541.0 → 0.543.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
@@ -5,7 +5,45 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.543.0 — 02.11.2026
9
+
10
+ ### Added
11
+
12
+ - **`events remind`: tell once about each event that starts soon, for cron or a timer to run every few minutes**,
13
+ as gcalcli's `remind` is run. It syncs first unless `--offline`, then takes the events that start within
14
+ `--due-within <duration>` (`10m` if not given, `1m` to `1d`) and take the owner's time: timed, not cancelled, not
15
+ declined, no focus time, out-of-office or working location. It prints each one, or, after `--`, runs the owner's
16
+ program once per event, with the event as JSON on its standard input and `<PREFIX>_EVENT=event:<id>` and
17
+ `<PREFIX>_PROFILE` in its environment. The program runs without a shell, and the event's text is never on its
18
+ command line: an invitation is a stranger's text. A program still running after 60 s is stopped.
19
+ - What was reminded is kept per profile in `<state>/checkpoints/<profile>/events-remind.json` (0600), one key per
20
+ event and start, so a moved meeting is reminded again. An event whose program failed is not kept, and the next
21
+ run tries it again; the answer is then `configuration_error` (`remind.hook-failed`). A lock keeps two runs from
22
+ reminding the same event. `--dry-run` runs, syncs and remembers nothing.
23
+ - A failed sync does not stop it: the answer says so in `meta.issues` and reminds from the store.
24
+ - The command needs the CLI's `writes` (`CalendarCommandDeps.writes`), whose settings it reads. It is never an MCP
25
+ tool.
26
+ - **`EVENTS_REMIND_HOOK_KEY` (`events.remind.hook`), in `CALENDAR_WRITE_KEYS` and `CALENDAR_WRITE_DEFAULTS` as
27
+ `deny`.** Running a program is a big permission, and `ask` would not be enough: `--yes` answers `ask`, and anything
28
+ that can type a command can add it. The owner allows it with `config set permissions.events.remind.hook allow`.
29
+ Under `<PREFIX>_READ_ONLY` the program never runs, whatever the level.
30
+ - **A pushed history batch tells its messenger whether it was saved.** `feed` and `listenHistory` take a
31
+ `HistoryBatchHandler`, `(batch) => void | Promise<boolean>`: the CLI's own handler resolves `true` once the batch is
32
+ in the local store and `false` when it could not be written, so an adapter can keep a batch it cannot get again
33
+ until it is stored. An adapter that ignores the answer works as before.
34
+
35
+ ### Changed — may break callers
36
+
37
+ - **`@wirecat/cli-base` 0.73.0**, for the verb `remind`. The peer range is now `>=0.73.0 <0.74.0`.
38
+
39
+ ## 0.542.0 — 02.11.2026
40
+
41
+ ### Added
42
+
43
+ - **`redirect: { kind: "loopback", host: "localhost" }` signs in through `http://localhost:<port>/callback`.** Microsoft
44
+ Entra ignores the port only for `localhost` redirect URIs, so a public-client app registered as
45
+ `http://localhost/callback` needs it. The listener then takes `127.0.0.1` and, when the host has IPv6, `::1`, because
46
+ the browser may resolve `localhost` to either. Without `host` nothing changes: Google and the rest keep `127.0.0.1`.
9
47
 
10
48
  ## 0.541.0 — 02.11.2026
11
49
 
@@ -157,6 +157,25 @@ export declare const calendarEvent: v.StrictObjectSchema<{
157
157
  }, undefined>, undefined>;
158
158
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
159
159
  }, undefined>;
160
+ export declare const calendarReminder: v.StrictObjectSchema<{
161
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
162
+ readonly event_series_id: v.NullableSchema<v.StringSchema<undefined>, undefined>;
163
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
164
+ readonly description: v.NullableSchema<v.StringSchema<undefined>, undefined>;
165
+ readonly location: v.NullableSchema<v.StringSchema<undefined>, undefined>;
166
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
167
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
168
+ readonly timezone: v.NullableSchema<v.StringSchema<undefined>, undefined>;
169
+ readonly origin: v.PicklistSchema<["auto", "owner", "calendar"], undefined>;
170
+ readonly status: v.NullableSchema<v.StringSchema<undefined>, undefined>;
171
+ readonly all_day: v.BooleanSchema<undefined>;
172
+ readonly source_deleted_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
173
+ readonly deleted_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
174
+ readonly created_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
175
+ readonly updated_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
176
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
177
+ readonly ref: v.StringSchema<undefined>;
178
+ }, undefined>;
160
179
  /**
161
180
  * A hit of the stored search (with `--backend both`, `server` too when the provider also found it), or one the
162
181
  * provider's own search found and nothing stored.
@@ -127,6 +127,9 @@ export const calendarEvent = v.strictObject({
127
127
  })),
128
128
  untrusted,
129
129
  });
130
+ /** One event `events remind` told about: the event, its reference, and the fields a stranger wrote. */
131
+ const { change: _change, changed_at: _changedAt, was: _was, overlapping: _overlapping, overlaps: _overlaps, ...listed } = event.entries;
132
+ export const calendarReminder = v.strictObject({ ...listed, ref: text });
130
133
  const hitFields = {
131
134
  provider: nullableText,
132
135
  account: nullableText,
@@ -241,6 +244,7 @@ const ANSWERS = {
241
244
  "events show": { name: "calendar_event", schema: calendarEvent },
242
245
  "events prepare": { name: "calendar_event_preparation", schema: calendarEventPreparation },
243
246
  "events export": { name: "calendar_export", schema: calendarExport },
247
+ "events remind": { name: "calendar_reminder", schema: calendarReminder },
244
248
  "events import": { name: "calendar_import", schema: calendarImport },
245
249
  "events create": { name: "calendar_event_write", schema: calendarEventWrite },
246
250
  "events update": { name: "calendar_event_write", schema: calendarEventWrite },
@@ -20,6 +20,7 @@ import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
20
20
  import { calendarStatus, syncCalendars } from "../sync.js";
21
21
  import { createEvent, createEventFromText, deleteEvent, EVENT_RESPONSES, IMPORT_MAX, importEvents, moveEvent, respondToEvent, updateEvent, } from "../writes.js";
22
22
  import { CALENDARS_QUERY, declareCalendarAnswers, PEOPLE_QUERY } from "./answers.js";
23
+ import { addRemind } from "./remind.js";
23
24
  const LOCAL_READ = { remote: "none", local: "read" };
24
25
  const LOCAL_WRITE = { remote: "none", local: "write" };
25
26
  const FETCH = { remote: "read", local: "write" };
@@ -300,6 +301,8 @@ const addCalendarCommandsDefinition = (program, deps) => {
300
301
  });
301
302
  print({ ...prepared, meta: { ...meta, ...window.meta, ...clockMeta(now(), zone()) } }, command);
302
303
  });
304
+ if (deps.writes)
305
+ addRemind(events, deps.writes, { deps, now, zone, print, example });
303
306
  const searches = annotate(program.command("search").description("find stored things by text: events"), {
304
307
  effects: GROUP,
305
308
  });
@@ -0,0 +1,12 @@
1
+ import type { Command } from "commander";
2
+ import type { CalendarWrites } from "../writes.js";
3
+ import type { CalendarCommandDeps } from "./index.js";
4
+ interface RemindHelpers {
5
+ deps: CalendarCommandDeps;
6
+ now: () => number;
7
+ zone: () => string;
8
+ print: (value: unknown, command: Command) => void;
9
+ example: (command: Command, ...lines: string[]) => Command;
10
+ }
11
+ export declare const addRemind: (events: Command, writes: CalendarWrites, { deps, now, zone, print, example }: RemindHelpers) => void;
12
+ export {};
@@ -0,0 +1,115 @@
1
+ import { CliError, resolvePaths } from "@wirecat/cli-base";
2
+ import { annotate } from "@wirecat/cli-base/commands";
3
+ import { clockMeta } from "@wirecat/cli-base/standards";
4
+ import { runBudgetOf } from "../../cli/context.js";
5
+ import { destroysNothing, FETCH } from "../../cli/effects.js";
6
+ import { terminalAsker } from "../../cli/messenger/ask.js";
7
+ import { parseDuration } from "../../domain/duration.js";
8
+ import { readOnlyRefusal } from "../../ports/app.js";
9
+ import { levelRefusal } from "../../sends/guard.js";
10
+ import { DEFAULT_PERMISSIONS, levelFor } from "../../sends/permissions.js";
11
+ import { dueEvents, openReminders, reminderItem, remindersPath, runHook } from "../remind.js";
12
+ import { syncCalendars } from "../sync.js";
13
+ import { CALENDAR_WRITE_DEFAULTS, EVENTS_REMIND_HOOK_KEY } from "../writer.js";
14
+ const REMIND_MIN_MS = 60_000;
15
+ const REMIND_MAX_MS = 86_400_000;
16
+ export const addRemind = (events, writes, { deps, now, zone, print, example }) => {
17
+ const remind = example(destroysNothing(annotate(events.command("remind"), { effects: FETCH, mcp: false }))
18
+ .description("tell once about each event that starts within --due-within: print it, or run your program for it with the " +
19
+ "event as JSON on its standard input; for cron or a timer every few minutes, syncing first unless --offline")
20
+ .summary("tell once about each event that starts soon, for a timer to run")
21
+ .argument("[program...]", "after --: the program to run for each event, and its own arguments; no shell. Needs events.remind.hook " +
22
+ "allowed"), "events remind --due-within 10m", "events remind --due-within 15m -- /home/me/bin/meeting-reminder")
23
+ .option("--due-within <duration>", "the events that start within this long from now, 1m to 1d", "10m")
24
+ .option("--dry-run", "say what it would remind; run nothing, sync nothing, remember nothing");
25
+ remind.action(async (program, opts, command) => {
26
+ const within = parseDuration(opts.dueWithin, "--due-within");
27
+ if (within < REMIND_MIN_MS || within > REMIND_MAX_MS)
28
+ throw new CliError("validation_error", "--due-within takes 1m to 1d", { reason: "remind.window" });
29
+ const settings = writes.settings();
30
+ const env = writes.env ?? process.env;
31
+ const hook = program.length > 0;
32
+ const defaults = { ...DEFAULT_PERMISSIONS, ...CALENDAR_WRITE_DEFAULTS, ...settings.permissionDefaults };
33
+ const { level, key: named } = levelFor(settings.permissions, EVENTS_REMIND_HOOK_KEY, defaults);
34
+ if (hook && !opts.dryRun) {
35
+ if (settings.readOnlyLock)
36
+ throw readOnlyRefusal(settings.readOnlyLock, "run a program for a reminder");
37
+ if (level === "deny" || level === "readonly")
38
+ throw levelRefusal({
39
+ profile: settings.profile,
40
+ command: writes.app.command,
41
+ key: EVENTS_REMIND_HOOK_KEY,
42
+ level,
43
+ named,
44
+ permissionSources: settings.permissionSources,
45
+ });
46
+ }
47
+ const at = now();
48
+ const issues = [];
49
+ const accountName = `${deps.account.provider}:${deps.account.account}`;
50
+ if (!opts.dryRun && deps.source && !command.optsWithGlobals().offline) {
51
+ // A reminder from what the store already holds beats none: a failed sync is said in meta and the run goes on.
52
+ try {
53
+ const report = await syncCalendars(deps.source, deps.store, {
54
+ accountId: deps.accountId,
55
+ now: at,
56
+ budget: runBudgetOf(command),
57
+ ...(deps.expand ? { expand: deps.expand } : {}),
58
+ });
59
+ if (report.synced.some((one) => one.error !== undefined))
60
+ issues.push({ code: "provider_error", reason: "sync.partial", source: accountName });
61
+ }
62
+ catch (error) {
63
+ issues.push({
64
+ code: error instanceof CliError ? error.code : "provider_error",
65
+ reason: "sync.failed",
66
+ source: accountName,
67
+ });
68
+ }
69
+ }
70
+ const window = { from: at, to: at + within };
71
+ const path = remindersPath(resolvePaths({ appName: writes.app.appName, prefix: writes.app.envPrefix, env }).state, settings.profile);
72
+ const reminders = opts.dryRun ? undefined : openReminders(path, at);
73
+ try {
74
+ const due = await dueEvents(deps.store, deps.accountId, window, reminders?.reminded ?? {});
75
+ if (hook && !opts.dryRun && due.length > 0 && level === "ask")
76
+ await terminalAsker(command)(EVENTS_REMIND_HOOK_KEY, { chatId: null, count: due.length });
77
+ const items = [];
78
+ const failed = [];
79
+ for (const details of due) {
80
+ const item = reminderItem(details);
81
+ if (hook && !opts.dryRun) {
82
+ const exitCode = await runHook(program, `${JSON.stringify(item)}\n`, {
83
+ ...env,
84
+ [`${writes.app.envPrefix}_PROFILE`]: settings.profile,
85
+ [`${writes.app.envPrefix}_EVENT`]: item.ref,
86
+ });
87
+ if (exitCode !== 0) {
88
+ failed.push({ ref: item.ref, exit_code: exitCode });
89
+ continue;
90
+ }
91
+ }
92
+ reminders?.remember(details);
93
+ items.push(item);
94
+ }
95
+ if (failed.length)
96
+ throw new CliError("configuration_error", `${program[0]} failed for ${failed.length} of ${due.length} events ` +
97
+ `(${failed.map((one) => `${one.ref}: exit ${one.exit_code}`).join(", ")}); the next run tries them again`, { reason: "remind.hook-failed", reminded: items.map(({ ref }) => ref), failed, retryable: true });
98
+ print({
99
+ items,
100
+ page: 1,
101
+ limit: Math.max(1, items.length),
102
+ hasMore: false,
103
+ meta: {
104
+ complete: issues.length === 0,
105
+ ...(issues.length ? { issues } : {}),
106
+ window: { since: new Date(window.from).toISOString(), until: new Date(window.to).toISOString() },
107
+ ...clockMeta(at, zone()),
108
+ },
109
+ }, command);
110
+ }
111
+ finally {
112
+ reminders?.close();
113
+ }
114
+ });
115
+ };
@@ -3,5 +3,5 @@ export { type IcsEvent, type IcsSkip, readIcs, writeIcs } from "./ics.js";
3
3
  export { repeatRule } from "./recurrence.js";
4
4
  export type { BusyTimes, CalendarExpander, CalendarSource, CalendarSourceTraits, ChangePage, ChangeResult, FreeBusyRequest, SearchPage, SyncWindow, } from "./source.js";
5
5
  export { type CalendarCursor, type CalendarSyncOptions, type CalendarSyncReport, type CalendarSyncResult, calendarStatus, calendarSyncWindow, type PendingRound, parseCalendarCursor, syncCalendars, } from "./sync.js";
6
- export { CALENDAR_WRITE_DEFAULTS, CALENDAR_WRITE_KEYS, type CalendarWrite, type CalendarWriter, calendarConflict, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, type EventDraft, type EventPatch, type EventResponse, type FollowingScope, type WriteScope, } from "./writer.js";
6
+ export { CALENDAR_WRITE_DEFAULTS, CALENDAR_WRITE_KEYS, type CalendarWrite, type CalendarWriter, calendarConflict, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, EVENTS_REMIND_HOOK_KEY, type EventDraft, type EventPatch, type EventResponse, type FollowingScope, type WriteScope, } from "./writer.js";
7
7
  export type { CalendarWrites } from "./writes.js";
@@ -1,4 +1,4 @@
1
1
  export { readIcs, writeIcs } from "./ics.js";
2
2
  export { repeatRule } from "./recurrence.js";
3
3
  export { calendarStatus, calendarSyncWindow, parseCalendarCursor, syncCalendars, } from "./sync.js";
4
- export { CALENDAR_WRITE_DEFAULTS, CALENDAR_WRITE_KEYS, calendarConflict, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
4
+ export { CALENDAR_WRITE_DEFAULTS, CALENDAR_WRITE_KEYS, calendarConflict, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, EVENTS_REMIND_HOOK_KEY, } from "./writer.js";
@@ -7,6 +7,11 @@ export declare const PREPARE_MESSAGES = 5;
7
7
  /** Attendees past this many are listed without their messages: a meeting of fifty would not fit any budget. */
8
8
  export declare const PREPARE_PEOPLE = 10;
9
9
  export declare const PREPARE_BYTES: number;
10
+ /**
11
+ * An event that takes the owner's time: timed, not cancelled, not declined by the owner, and an ordinary event (no
12
+ * focus time, out-of-office or working location).
13
+ */
14
+ export declare const ownsTime: (details: EventDetails) => boolean;
10
15
  /** The account's next meeting that has not started yet, by the rule of `isMeeting`. */
11
16
  export declare const nextMeeting: (store: EventStore, accountId: number, now: number) => Promise<EventDetails>;
12
17
  export interface PreparationOptions {
@@ -16,19 +16,19 @@ const NEXT_PAGE = 50;
16
16
  const nameOf = ({ provider, account }) => `${provider}:${account}`;
17
17
  const size = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
18
18
  /**
19
- * What makes an event the next meeting: timed, not cancelled, not declined by the owner, an ordinary event (no focus
20
- * time, out-of-office or working location), and someone other than the owner and the rooms invited.
19
+ * An event that takes the owner's time: timed, not cancelled, not declined by the owner, and an ordinary event (no
20
+ * focus time, out-of-office or working location).
21
21
  */
22
- const isMeeting = (details) => {
22
+ export const ownsTime = (details) => {
23
23
  if (details.event.allDay || details.event.status === "cancelled")
24
24
  return false;
25
25
  const live = details.entries.filter((entry) => entry.deletedAt === null && entry.status !== "cancelled");
26
26
  if (!live.length || live.every((entry) => entry.ownResponse === "declined"))
27
27
  return false;
28
- if (live.every((entry) => entry.eventType !== null && entry.eventType !== "default"))
29
- return false;
30
- return details.participants.some((one) => !one.self && !one.resource);
28
+ return !live.every((entry) => entry.eventType !== null && entry.eventType !== "default");
31
29
  };
30
+ /** The next meeting: an event that takes the owner's time, with someone other than the owner and the rooms invited. */
31
+ const isMeeting = (details) => ownsTime(details) && details.participants.some((one) => !one.self && !one.resource);
32
32
  /** The account's next meeting that has not started yet, by the rule of `isMeeting`. */
33
33
  export const nextMeeting = async (store, accountId, now) => {
34
34
  for (let offset = 0; offset < NEXT_SCAN; offset += NEXT_PAGE) {
@@ -0,0 +1,51 @@
1
+ import { type EventDetails, type EventStore } from "../meetings/index.js";
2
+ /** A program that has not finished by then is stopped, so a stuck one does not hold the next run's lock. */
3
+ export declare const HOOK_TIMEOUT_MS = 60000;
4
+ export type Reminded = Record<string, string>;
5
+ export declare const remindersPath: (state: string, profile: string) => string;
6
+ /** Events that take the owner's time and start in the window, earliest first, and are not yet reminded. */
7
+ export declare const dueEvents: (store: EventStore, accountId: number, window: {
8
+ from: number;
9
+ to: number;
10
+ }, reminded: Reminded) => Promise<EventDetails[]>;
11
+ export declare const reminderItem: (details: EventDetails) => {
12
+ id: string;
13
+ event_series_id: string | null;
14
+ title: string | null;
15
+ description: string | null;
16
+ location: string | null;
17
+ starts_at: string | null;
18
+ ends_at: string | null;
19
+ timezone: string | null;
20
+ origin: "auto" | "calendar" | "owner";
21
+ status: string | null;
22
+ all_day: boolean;
23
+ source_deleted_at: string | null;
24
+ deleted_at: string | null;
25
+ created_at: string;
26
+ updated_at: string;
27
+ change?: "cancelled" | "moved" | "new" | undefined;
28
+ changed_at?: string | null | undefined;
29
+ was?: {
30
+ starts_at: string | null;
31
+ ends_at: string | null;
32
+ } | undefined;
33
+ overlapping?: boolean | undefined;
34
+ overlaps?: string[] | undefined;
35
+ ref: string;
36
+ untrusted: string[];
37
+ };
38
+ /**
39
+ * Runs the owner's program with the reminder as JSON on its standard input, never through a shell: the event's text
40
+ * is a stranger's, and only the program's own argv, typed by the owner, is on its command line.
41
+ */
42
+ export declare const runHook: (argv: readonly string[], input: string, env: NodeJS.ProcessEnv, timeoutMs?: number) => Promise<number>;
43
+ /**
44
+ * What was reminded, and the lock that keeps two runs from reminding the same event at once. `remember` keeps a day
45
+ * of past starts and forgets older ones.
46
+ */
47
+ export declare const openReminders: (path: string, now: number) => {
48
+ reminded: Reminded;
49
+ remember: (details: EventDetails) => void;
50
+ close: () => void;
51
+ };
@@ -0,0 +1,79 @@
1
+ import { spawn } from "node:child_process";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ import { checkpointPath, readCheckpoint, saveCheckpoint } from "../background/checkpoints.js";
4
+ import { releaseLock, takeLock } from "../background/lock.js";
5
+ import { eventItem } from "../meetings/index.js";
6
+ import { ownsTime } from "./prepare.js";
7
+ import { eventOf } from "./reads.js";
8
+ const DAY = 86_400_000;
9
+ /** More would be a calendar that is not the owner's own; the rest wait for the next run. */
10
+ const REMIND_MAX = 50;
11
+ /** A program that has not finished by then is stopped, so a stuck one does not hold the next run's lock. */
12
+ export const HOOK_TIMEOUT_MS = 60_000;
13
+ const KILL_AFTER_MS = 5_000;
14
+ const UNTRUSTED = ["title", "description", "location"];
15
+ /** One key per event and start: a meeting moved to another time is reminded again. */
16
+ const keyOf = (details) => `${details.event.id}@${new Date(details.event.startsAt ?? 0).toISOString()}`;
17
+ export const remindersPath = (state, profile) => checkpointPath(state, profile, "events-remind");
18
+ /** Events that take the owner's time and start in the window, earliest first, and are not yet reminded. */
19
+ export const dueEvents = async (store, accountId, window, reminded) => {
20
+ const due = [];
21
+ for (const one of await store.events({ accountId, startsFrom: window.from, to: window.to, limit: REMIND_MAX })) {
22
+ const details = await eventOf(store, accountId, one.id).catch(() => undefined);
23
+ if (details && ownsTime(details) && reminded[keyOf(details)] === undefined)
24
+ due.push(details);
25
+ }
26
+ return due;
27
+ };
28
+ export const reminderItem = (details) => ({
29
+ ...eventItem(details.event),
30
+ ref: `event:${details.event.id}`,
31
+ untrusted: UNTRUSTED,
32
+ });
33
+ /**
34
+ * Runs the owner's program with the reminder as JSON on its standard input, never through a shell: the event's text
35
+ * is a stranger's, and only the program's own argv, typed by the owner, is on its command line.
36
+ */
37
+ export const runHook = (argv, input, env, timeoutMs = HOOK_TIMEOUT_MS) => new Promise((resolve) => {
38
+ const [program = "", ...args] = argv;
39
+ const child = spawn(program, args, { env, shell: false, stdio: ["pipe", "ignore", "inherit"], windowsHide: true });
40
+ let kill;
41
+ const stop = setTimeout(() => {
42
+ child.kill("SIGTERM");
43
+ kill = setTimeout(() => child.kill("SIGKILL"), KILL_AFTER_MS);
44
+ }, timeoutMs);
45
+ const done = (code) => {
46
+ clearTimeout(stop);
47
+ if (kill)
48
+ clearTimeout(kill);
49
+ resolve(code);
50
+ };
51
+ child.on("error", () => done(127));
52
+ child.on("close", (code, signal) => done(code ?? (signal === "SIGKILL" ? 137 : 143)));
53
+ // A program that reads nothing closes its end first; that is its choice, not a failure.
54
+ child.stdin.on("error", () => { });
55
+ child.stdin.end(input);
56
+ });
57
+ /**
58
+ * What was reminded, and the lock that keeps two runs from reminding the same event at once. `remember` keeps a day
59
+ * of past starts and forgets older ones.
60
+ */
61
+ export const openReminders = (path, now) => {
62
+ const lock = `${path}.lock`;
63
+ const held = takeLock(lock, { pid: process.pid, startedAt: new Date(now).toISOString() });
64
+ if (held)
65
+ throw new CliError("validation_error", `another events remind is running (pid ${held.pid}); this run did nothing`, {
66
+ reason: "remind.running",
67
+ retryable: true,
68
+ });
69
+ const reminded = { ...readCheckpoint(path)?.cursor };
70
+ return {
71
+ reminded,
72
+ remember: (details) => {
73
+ reminded[keyOf(details)] = new Date(now).toISOString();
74
+ const kept = Object.fromEntries(Object.entries(reminded).filter(([key]) => Date.parse(key.slice(key.indexOf("@") + 1)) >= now - DAY));
75
+ saveCheckpoint(path, { cursor: kept, done: Object.keys(kept).length }, new Date(now));
76
+ },
77
+ close: () => releaseLock(lock),
78
+ };
79
+ };
@@ -93,6 +93,8 @@ export type CalendarWrite = "create" | "quick-add" | "import" | "update" | "dele
93
93
  /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
94
94
  export declare const EVENTS_NOTIFY_KEY: PermissionKey;
95
95
  export declare const EVENTS_DELETE_SERIES_KEY: PermissionKey;
96
+ /** `events remind -- <program>`: runs a program of the owner's on this machine, so it waits for the owner's allow. */
97
+ export declare const EVENTS_REMIND_HOOK_KEY: PermissionKey;
96
98
  /** The keys the calendar writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
97
99
  export declare const CALENDAR_WRITE_KEYS: readonly PermissionKey[];
98
100
  /**
@@ -2,8 +2,14 @@ import { CliError } from "@wirecat/cli-base";
2
2
  /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
3
3
  export const EVENTS_NOTIFY_KEY = "events.notify";
4
4
  export const EVENTS_DELETE_SERIES_KEY = "events.delete.series";
5
+ /** `events remind -- <program>`: runs a program of the owner's on this machine, so it waits for the owner's allow. */
6
+ export const EVENTS_REMIND_HOOK_KEY = "events.remind.hook";
5
7
  /** The keys the calendar writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
6
- export const CALENDAR_WRITE_KEYS = [EVENTS_NOTIFY_KEY, EVENTS_DELETE_SERIES_KEY];
8
+ export const CALENDAR_WRITE_KEYS = [
9
+ EVENTS_NOTIFY_KEY,
10
+ EVENTS_DELETE_SERIES_KEY,
11
+ EVENTS_REMIND_HOOK_KEY,
12
+ ];
7
13
  /**
8
14
  * Built in, and only ever tightening (WC-PERM-7). A change that is easy to undo goes ahead, as tg's and drive's writes
9
15
  * do; a deletion asks; a whole series is not deleted, and nobody is mailed.
@@ -18,6 +24,8 @@ export const CALENDAR_WRITE_DEFAULTS = {
18
24
  "events.delete": "ask",
19
25
  [EVENTS_DELETE_SERIES_KEY]: "deny",
20
26
  [EVENTS_NOTIFY_KEY]: "deny",
27
+ // Not ask: `--yes` answers ask, and anything that can type a gcal command can add it.
28
+ [EVENTS_REMIND_HOOK_KEY]: "deny",
21
29
  };
22
30
  /** What an adapter throws when the provider refused a write because the entry's `etag` is old (HTTP 412). */
23
31
  export const calendarConflict = (message = "the event changed at the provider since the last sync") => new CliError("validation_error", `${message}; sync and try again`, { reason: "event.conflict" });
@@ -14,7 +14,7 @@ export { registerLinkConversationsPrompt } from "../mcp/prompts.js";
14
14
  export { agentBody, agentJson, agentResult, UNTRUSTED, UNTRUSTED_INSTRUCTION, UNTRUSTED_NEUTRAL } from "../mcp/text.js";
15
15
  export { MEETING_RESULT_ENTITIES } from "../meetings/index.js";
16
16
  export { type AppIdentity, envName, readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
17
- export type { AccountEditing, AccountHealth, AccountRecords, AccountSettings, AccountStanding, AccountTools, After, BotChats, ChannelComments, ChatDeletion, ChatFolders, ChatReading, ContactBook, Download, ForumControl, ForumState, GroupAdmin, GroupModeration, HistoryBatch, HistoryChatEvent, HistoryReaction, HistoryStar, HistoryVote, InviteLinks, JoinRequests, LiveUpdates, MessageButtons, MessageEditing, MessageMedia, MessagePermalinks, MessagePins, MessagePolls, MessageReactions, MessageSearch, MessageStars, MessengerAdapter, MessengerCore, NewPoll, OfficialStats, PassiveUpdates, PersonProfiles, ProfileChange, ProfilePhotos, PushedHistory, ReadState, RemoteFile, ScheduledMessages, SenderIdentities, SenderSearch, SendOptions, Sent, ServerQuery, ServerReads, ThreadAddressing, TopicEditing, TopicHistory, TopicReadState, Transcript, } from "../ports/messenger.js";
17
+ export type { AccountEditing, AccountHealth, AccountRecords, AccountSettings, AccountStanding, AccountTools, After, BotChats, ChannelComments, ChatDeletion, ChatFolders, ChatReading, ContactBook, Download, ForumControl, ForumState, GroupAdmin, GroupModeration, HistoryBatch, HistoryBatchHandler, HistoryChatEvent, HistoryReaction, HistoryStar, HistoryVote, InviteLinks, JoinRequests, LiveUpdates, MessageButtons, MessageEditing, MessageMedia, MessagePermalinks, MessagePins, MessagePolls, MessageReactions, MessageSearch, MessageStars, MessengerAdapter, MessengerCore, NewPoll, OfficialStats, PassiveUpdates, PersonProfiles, ProfileChange, ProfilePhotos, PushedHistory, ReadState, RemoteFile, ScheduledMessages, SenderIdentities, SenderSearch, SendOptions, Sent, ServerQuery, ServerReads, ThreadAddressing, TopicEditing, TopicHistory, TopicReadState, Transcript, } from "../ports/messenger.js";
18
18
  export type { Fetching, MediaOption, MessengerTraits } from "../ports/traits.js";
19
19
  export { senderFacts } from "../replies/dry-run.js";
20
20
  export { type ReplyRender, replyRenderer } from "../replies/rendering.js";
@@ -44,7 +44,7 @@ export const observed = (messenger, events) => {
44
44
  const counts = {};
45
45
  await timed(operation, {}, () => work((item) => {
46
46
  count(item, counts);
47
- onItem(item);
47
+ return onItem(item);
48
48
  }), () => ({ counts }));
49
49
  };
50
50
  const mint = messenger.newSendId;
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
- error?: undefined;
7
6
  path: string;
8
7
  exists: boolean;
8
+ error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -10,7 +10,7 @@ export interface Saving {
10
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
- pending?: Set<Promise<void>>;
13
+ pending?: Set<Promise<unknown>>;
14
14
  /** `storedChats` calls still running, apart from `pending`: one abandoned at exit leaves nothing unsaved. */
15
15
  announcing?: Set<Promise<void>>;
16
16
  deletedWithoutChat?: DeletionScope;
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { capability, throughWrapper } from "../../ports/messenger.js";
2
+ import { capability, throughWrapper, } from "../../ports/messenger.js";
3
3
  import { foldName, isId, pickPerson } from "../../resolve.js";
4
4
  import { codeOf } from "../../sends/guarded.js";
5
5
  import { chatIdIn } from "../../services/message-resolution.js";
@@ -25,8 +25,9 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
25
25
  const attempt = async (operation, write) => {
26
26
  try {
27
27
  const opened = await store();
28
- if (opened)
29
- await write(opened);
28
+ if (!opened)
29
+ return false;
30
+ await write(opened);
30
31
  // After the write, so saves keep their order; a convenience for naming this account from another app.
31
32
  if (opened && !recorded && profile) {
32
33
  recorded = true;
@@ -34,9 +35,11 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
34
35
  .then(() => opened.accounts.saveProfile(profile.app, profile.name, account))
35
36
  .catch(() => { });
36
37
  }
38
+ return true;
37
39
  }
38
40
  catch (error) {
39
41
  notWritten(operation, error);
42
+ return false;
40
43
  }
41
44
  };
42
45
  const save = (operation, writing) => {
@@ -152,7 +155,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
152
155
  // A refused re-key leaves its chat split, never the others or the batch's history unsaved.
153
156
  const saving = previous.then(async () => {
154
157
  await saveRekeys(rekeys, round);
155
- await attempt(operation, (opened) => fill(opened, account, batch));
158
+ return attempt(operation, (opened) => fill(opened, account, batch));
156
159
  });
157
160
  previous = saving;
158
161
  pending?.add(saving);
@@ -162,7 +165,8 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
162
165
  pending?.delete(saving);
163
166
  rekeysOnly.delete(saving);
164
167
  });
165
- onBatch(batch);
168
+ void onBatch(batch);
169
+ return saving;
166
170
  };
167
171
  };
168
172
  // A method not listed here saves nothing and passes through; a lane that should save adds its line.
@@ -57,6 +57,14 @@ const OTHERS = [
57
57
  ["sync.partial", "provider_error", "some calendars did not sync; the next sync retries them"],
58
58
  validation("event.conflict", "the event changed at the provider since the last sync; sync and try again"),
59
59
  validation("event.notify-required", "the event has other attendees; say whether to notify them"),
60
+ validation("remind.window", "--due-within is outside 1m to 1d"),
61
+ validation("remind.running", "another events remind is running; this run did nothing"),
62
+ [
63
+ "remind.hook-failed",
64
+ "configuration_error",
65
+ "the reminder program failed for some events; the next run retries them",
66
+ ],
67
+ ["sync.failed", "provider_error", "the sync failed; the answer comes from what the store already holds"],
60
68
  validation("event.repeat-invalid", "the repeat rule is outside the RFC 5545 subset the calendars take"),
61
69
  validation("event.repeat-kind", "the series' rule holds a date or a time of the kind the event is leaving"),
62
70
  validation("event.repeat-split", "the series cannot be split at this occurrence, or was split there already"),
@@ -38,6 +38,7 @@ export declare const meetingTools: ({
38
38
  max_read_bytes?: undefined;
39
39
  cursor?: undefined;
40
40
  } | {
41
+ limit?: undefined;
41
42
  event_id?: undefined;
42
43
  series_id?: undefined;
43
44
  page?: undefined;
@@ -53,11 +54,11 @@ export declare const meetingTools: ({
53
54
  format?: undefined;
54
55
  transcript_id?: undefined;
55
56
  query?: undefined;
56
- limit?: undefined;
57
57
  scan_limit?: undefined;
58
58
  max_read_bytes?: undefined;
59
59
  cursor?: undefined;
60
60
  } | {
61
+ limit?: undefined;
61
62
  event_id?: undefined;
62
63
  series_id?: undefined;
63
64
  page?: undefined;
@@ -75,11 +76,11 @@ export declare const meetingTools: ({
75
76
  format?: undefined;
76
77
  transcript_id?: undefined;
77
78
  query?: undefined;
78
- limit?: undefined;
79
79
  scan_limit?: undefined;
80
80
  max_read_bytes?: undefined;
81
81
  cursor?: undefined;
82
82
  } | {
83
+ limit?: undefined;
83
84
  event_id?: undefined;
84
85
  series_id?: undefined;
85
86
  page?: undefined;
@@ -101,7 +102,6 @@ export declare const meetingTools: ({
101
102
  minimum: number;
102
103
  };
103
104
  query?: undefined;
104
- limit?: undefined;
105
105
  scan_limit?: undefined;
106
106
  max_read_bytes?: undefined;
107
107
  cursor?: undefined;
@@ -188,6 +188,7 @@ export declare const meetingTools: ({
188
188
  max_read_bytes?: undefined;
189
189
  cursor?: undefined;
190
190
  } | {
191
+ limit?: undefined;
191
192
  event_id?: undefined;
192
193
  series_id?: undefined;
193
194
  page?: undefined;
@@ -203,11 +204,11 @@ export declare const meetingTools: ({
203
204
  format?: undefined;
204
205
  transcript_id?: undefined;
205
206
  query?: undefined;
206
- limit?: undefined;
207
207
  scan_limit?: undefined;
208
208
  max_read_bytes?: undefined;
209
209
  cursor?: undefined;
210
210
  } | {
211
+ limit?: undefined;
211
212
  event_id?: undefined;
212
213
  series_id?: undefined;
213
214
  page?: undefined;
@@ -225,11 +226,11 @@ export declare const meetingTools: ({
225
226
  format?: undefined;
226
227
  transcript_id?: undefined;
227
228
  query?: undefined;
228
- limit?: undefined;
229
229
  scan_limit?: undefined;
230
230
  max_read_bytes?: undefined;
231
231
  cursor?: undefined;
232
232
  } | {
233
+ limit?: undefined;
233
234
  event_id?: undefined;
234
235
  series_id?: undefined;
235
236
  page?: undefined;
@@ -251,7 +252,6 @@ export declare const meetingTools: ({
251
252
  minimum: number;
252
253
  };
253
254
  query?: undefined;
254
- limit?: undefined;
255
255
  scan_limit?: undefined;
256
256
  max_read_bytes?: undefined;
257
257
  cursor?: undefined;
@@ -338,6 +338,7 @@ export declare const meetingTools: ({
338
338
  max_read_bytes?: undefined;
339
339
  cursor?: undefined;
340
340
  } | {
341
+ limit?: undefined;
341
342
  event_id?: undefined;
342
343
  series_id?: undefined;
343
344
  page?: undefined;
@@ -353,11 +354,11 @@ export declare const meetingTools: ({
353
354
  format?: undefined;
354
355
  transcript_id?: undefined;
355
356
  query?: undefined;
356
- limit?: undefined;
357
357
  scan_limit?: undefined;
358
358
  max_read_bytes?: undefined;
359
359
  cursor?: undefined;
360
360
  } | {
361
+ limit?: undefined;
361
362
  event_id?: undefined;
362
363
  series_id?: undefined;
363
364
  page?: undefined;
@@ -375,11 +376,11 @@ export declare const meetingTools: ({
375
376
  format?: undefined;
376
377
  transcript_id?: undefined;
377
378
  query?: undefined;
378
- limit?: undefined;
379
379
  scan_limit?: undefined;
380
380
  max_read_bytes?: undefined;
381
381
  cursor?: undefined;
382
382
  } | {
383
+ limit?: undefined;
383
384
  event_id?: undefined;
384
385
  series_id?: undefined;
385
386
  page?: undefined;
@@ -401,7 +402,6 @@ export declare const meetingTools: ({
401
402
  minimum: number;
402
403
  };
403
404
  query?: undefined;
404
- limit?: undefined;
405
405
  scan_limit?: undefined;
406
406
  max_read_bytes?: undefined;
407
407
  cursor?: undefined;
@@ -488,6 +488,7 @@ export declare const meetingTools: ({
488
488
  max_read_bytes?: undefined;
489
489
  cursor?: undefined;
490
490
  } | {
491
+ limit?: undefined;
491
492
  event_id?: undefined;
492
493
  series_id?: undefined;
493
494
  page?: undefined;
@@ -503,11 +504,11 @@ export declare const meetingTools: ({
503
504
  format?: undefined;
504
505
  transcript_id?: undefined;
505
506
  query?: undefined;
506
- limit?: undefined;
507
507
  scan_limit?: undefined;
508
508
  max_read_bytes?: undefined;
509
509
  cursor?: undefined;
510
510
  } | {
511
+ limit?: undefined;
511
512
  event_id?: undefined;
512
513
  series_id?: undefined;
513
514
  page?: undefined;
@@ -525,11 +526,11 @@ export declare const meetingTools: ({
525
526
  format?: undefined;
526
527
  transcript_id?: undefined;
527
528
  query?: undefined;
528
- limit?: undefined;
529
529
  scan_limit?: undefined;
530
530
  max_read_bytes?: undefined;
531
531
  cursor?: undefined;
532
532
  } | {
533
+ limit?: undefined;
533
534
  event_id?: undefined;
534
535
  series_id?: undefined;
535
536
  page?: undefined;
@@ -551,7 +552,6 @@ export declare const meetingTools: ({
551
552
  minimum: number;
552
553
  };
553
554
  query?: undefined;
554
- limit?: undefined;
555
555
  scan_limit?: undefined;
556
556
  max_read_bytes?: undefined;
557
557
  cursor?: undefined;
@@ -638,6 +638,7 @@ export declare const meetingTools: ({
638
638
  max_read_bytes?: undefined;
639
639
  cursor?: undefined;
640
640
  } | {
641
+ limit?: undefined;
641
642
  event_id?: undefined;
642
643
  series_id?: undefined;
643
644
  page?: undefined;
@@ -653,11 +654,11 @@ export declare const meetingTools: ({
653
654
  format?: undefined;
654
655
  transcript_id?: undefined;
655
656
  query?: undefined;
656
- limit?: undefined;
657
657
  scan_limit?: undefined;
658
658
  max_read_bytes?: undefined;
659
659
  cursor?: undefined;
660
660
  } | {
661
+ limit?: undefined;
661
662
  event_id?: undefined;
662
663
  series_id?: undefined;
663
664
  page?: undefined;
@@ -675,11 +676,11 @@ export declare const meetingTools: ({
675
676
  format?: undefined;
676
677
  transcript_id?: undefined;
677
678
  query?: undefined;
678
- limit?: undefined;
679
679
  scan_limit?: undefined;
680
680
  max_read_bytes?: undefined;
681
681
  cursor?: undefined;
682
682
  } | {
683
+ limit?: undefined;
683
684
  event_id?: undefined;
684
685
  series_id?: undefined;
685
686
  page?: undefined;
@@ -701,7 +702,6 @@ export declare const meetingTools: ({
701
702
  minimum: number;
702
703
  };
703
704
  query?: undefined;
704
- limit?: undefined;
705
705
  scan_limit?: undefined;
706
706
  max_read_bytes?: undefined;
707
707
  cursor?: undefined;
@@ -46,7 +46,7 @@ export const authorize = async (options) => {
46
46
  else
47
47
  fail(new CliError("authentication_error", `${provider.name} sign-in was declined`, { reason: "oauth.declined" }));
48
48
  };
49
- const server = createServer((request, response) => {
49
+ const handler = (request, response) => {
50
50
  let url;
51
51
  try {
52
52
  url = new URL(request.url ?? "/", "http://127.0.0.1");
@@ -69,7 +69,11 @@ export const authorize = async (options) => {
69
69
  }
70
70
  response.end("Sign-in received; you can close this tab");
71
71
  settle(url.searchParams);
72
- });
72
+ };
73
+ const server = createServer(handler);
74
+ const localhost = provider.redirect.kind === "loopback" && provider.redirect.host === "localhost";
75
+ // `localhost` may resolve to ::1 in the browser; the IPv6 listener is best effort, as some hosts have no IPv6.
76
+ const server6 = localhost ? createServer(handler) : undefined;
73
77
  const onAbort = () => fail(cancelled());
74
78
  options.signal?.addEventListener("abort", onAbort, { once: true });
75
79
  const timer = setTimeout(() => fail(new CliError("timeout", `${provider.name} sign-in timed out`, { reason: "oauth.timeout" })), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -81,10 +85,19 @@ export const authorize = async (options) => {
81
85
  const address = server.address();
82
86
  if (!address || typeof address === "string")
83
87
  throw new CliError("configuration_error", "could not start the local sign-in listener");
88
+ if (server6) {
89
+ server6.on("error", () => { });
90
+ await new Promise((resolve) => {
91
+ server6.once("error", () => resolve());
92
+ server6.listen(address.port, "::1", () => resolve());
93
+ });
94
+ }
84
95
  const random = randomBytes(32).toString("base64url");
85
96
  const relay = provider.redirect.kind === "relay";
86
97
  state = relay ? `${address.port}.${random}` : random;
87
- const redirectUri = provider.redirect.kind === "relay" ? provider.redirect.url : `http://127.0.0.1:${address.port}/callback`;
98
+ const redirectUri = provider.redirect.kind === "relay"
99
+ ? provider.redirect.url
100
+ : `http://${localhost ? "localhost" : "127.0.0.1"}:${address.port}/callback`;
88
101
  const url = new URL(provider.authorizeUrl);
89
102
  for (const [key, value] of Object.entries({
90
103
  ...provider.authorizeParams,
@@ -124,7 +137,10 @@ export const authorize = async (options) => {
124
137
  clearTimeout(timer);
125
138
  options.signal?.removeEventListener("abort", onAbort);
126
139
  paste.abort();
127
- server.closeAllConnections();
128
- await new Promise((resolve) => server.close(() => resolve()));
140
+ for (const each of server6 ? [server, server6] : [server]) {
141
+ each.closeAllConnections();
142
+ if (each.listening)
143
+ await new Promise((resolve) => each.close(() => resolve()));
144
+ }
129
145
  }
130
146
  };
@@ -39,11 +39,13 @@ export interface OAuthProvider {
39
39
  revokeUrl?: string;
40
40
  client: OAuthClient;
41
41
  /**
42
- * `loopback` sends the browser to `http://127.0.0.1:<port>/callback`. `relay` sends it to a fixed page that
42
+ * `loopback` sends the browser to `http://127.0.0.1:<port>/callback`, or to `http://localhost:<port>/callback` with
43
+ * `host: "localhost"` (the listener then takes `127.0.0.1` and `::1`). `relay` sends it to a fixed page that
43
44
  * forwards to the loopback port, which it reads from `state` (`<port>.<random>`).
44
45
  */
45
46
  redirect: {
46
47
  kind: "loopback";
48
+ host?: "localhost";
47
49
  } | {
48
50
  kind: "relay";
49
51
  url: string;
@@ -348,7 +348,7 @@ export interface PassiveUpdates {
348
348
  * has it saved by a command that never feeds. Optional within the group, so an adapter typed as having
349
349
  * `PassiveUpdates` before it existed still is one.
350
350
  */
351
- listenHistory?(onBatch: (batch: HistoryBatch) => void, signal: AbortSignal): Promise<void>;
351
+ listenHistory?(onBatch: HistoryBatchHandler, signal: AbortSignal): Promise<void>;
352
352
  }
353
353
  /** One voter's vote in the poll message `messageId` of `chatId` carries, as `PollVoteEvent` has it. */
354
354
  export interface HistoryVote {
@@ -398,10 +398,16 @@ export interface HistoryBatch {
398
398
  }[];
399
399
  events?: HistoryChatEvent[];
400
400
  }
401
+ /**
402
+ * Takes one pushed batch. The CLI's own handler resolves `true` once the batch is in the local store and `false` when it
403
+ * could not be written, so a messenger that pushes history once can keep a batch until it is saved; a handler that
404
+ * stores nothing returns nothing.
405
+ */
406
+ export type HistoryBatchHandler = (batch: HistoryBatch) => void | Promise<boolean>;
401
407
  /** For a messenger that pushes its history to the client instead of answering for it (`Messenger.history: "store"`). */
402
408
  export interface PushedHistory {
403
409
  /** Chats, people and past messages as the messenger pushes them, until `signal` aborts. */
404
- feed(onBatch: (batch: HistoryBatch) => void, signal: AbortSignal): Promise<void>;
410
+ feed(onBatch: HistoryBatchHandler, signal: AbortSignal): Promise<void>;
405
411
  /**
406
412
  * Every chat id the store holds for this account, handed over once as `feed` starts, so a chat nothing names again
407
413
  * can still be re-keyed through a later batch's `rekeys`. `recipients`, when given, are ids the profile's recipient
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.541.0",
3
+ "version": "0.543.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",
@@ -251,7 +251,7 @@
251
251
  "@types/node": "^22.20.5",
252
252
  "@types/pngjs": "6.0.5",
253
253
  "@vitest/coverage-v8": "^5.0.5",
254
- "@wirecat/cli-base": "0.72.0",
254
+ "@wirecat/cli-base": "0.73.0",
255
255
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
256
256
  "commander": "15.0.0",
257
257
  "cspell": "10.4.0",
@@ -271,7 +271,7 @@
271
271
  },
272
272
  "peerDependencies": {
273
273
  "@napi-rs/canvas": ">=0.1.69 <2",
274
- "@wirecat/cli-base": ">=0.72.0 <0.73.0",
274
+ "@wirecat/cli-base": ">=0.73.0 <0.74.0",
275
275
  "commander": ">=15"
276
276
  },
277
277
  "bin": {