@wirecat/cli-core 0.542.0 → 0.544.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,59 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.544.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **The first run of a new version refreshes the agent instructions an older version installed**: each `SKILL.md`
15
+ copy whose `skill status` is `stale` (Claude Code's, `~/.agents/skills`, and Hermes's own copy) is rewritten once,
16
+ with one line on stderr — `Updated the tg instructions for Claude Code and other agents to 0.112.0`. A missing copy
17
+ stays missing, an `unversioned` one (no released `skill install` wrote it) and a symlink are left as they are, and a
18
+ failure is one note, never a failed command. The version tried is kept in `<state>/skill-refresh.json`. Off in a
19
+ moved run, under vitest, during `skill`, and with `<PREFIX>_NO_SKILL_REFRESH=1`; `--quiet` keeps the line out.
20
+
21
+ ### Changed — may break callers
22
+
23
+ - **An agent's own shell may tighten a permission but never loosen it.** `config set|unset permissions…` run where
24
+ `AI_AGENT`, `CLAUDECODE`, `OPENCLAW_SHELL=exec` or another agent marker is set is now treated like an MCP agent's
25
+ call: `mcp.authority` and `bot.api` stay owner-only, and a level may only go down (`readonly` → `deny`, never
26
+ `readonly` → `allow`). Unsetting a permission, replacing `permissions` whole, or `--defaults`/`--personal`/`--bot`
27
+ accept only `deny`. Before, Hermes or OpenClaw could undo the read-only the desktop app set, from any chat message
28
+ that talked it into running `config set`. The owner changes them in a terminal outside the agent; the desktop app
29
+ already runs the tools without these variables. Not a sandbox: an agent with a general shell can still edit the
30
+ file itself, so keep its own command approvals on.
31
+
32
+ ## 0.543.0 — 02.11.2026
33
+
34
+ ### Added
35
+
36
+ - **`events remind`: tell once about each event that starts soon, for cron or a timer to run every few minutes**,
37
+ as gcalcli's `remind` is run. It syncs first unless `--offline`, then takes the events that start within
38
+ `--due-within <duration>` (`10m` if not given, `1m` to `1d`) and take the owner's time: timed, not cancelled, not
39
+ declined, no focus time, out-of-office or working location. It prints each one, or, after `--`, runs the owner's
40
+ program once per event, with the event as JSON on its standard input and `<PREFIX>_EVENT=event:<id>` and
41
+ `<PREFIX>_PROFILE` in its environment. The program runs without a shell, and the event's text is never on its
42
+ command line: an invitation is a stranger's text. A program still running after 60 s is stopped.
43
+ - What was reminded is kept per profile in `<state>/checkpoints/<profile>/events-remind.json` (0600), one key per
44
+ event and start, so a moved meeting is reminded again. An event whose program failed is not kept, and the next
45
+ run tries it again; the answer is then `configuration_error` (`remind.hook-failed`). A lock keeps two runs from
46
+ reminding the same event. `--dry-run` runs, syncs and remembers nothing.
47
+ - A failed sync does not stop it: the answer says so in `meta.issues` and reminds from the store.
48
+ - The command needs the CLI's `writes` (`CalendarCommandDeps.writes`), whose settings it reads. It is never an MCP
49
+ tool.
50
+ - **`EVENTS_REMIND_HOOK_KEY` (`events.remind.hook`), in `CALENDAR_WRITE_KEYS` and `CALENDAR_WRITE_DEFAULTS` as
51
+ `deny`.** Running a program is a big permission, and `ask` would not be enough: `--yes` answers `ask`, and anything
52
+ that can type a command can add it. The owner allows it with `config set permissions.events.remind.hook allow`.
53
+ Under `<PREFIX>_READ_ONLY` the program never runs, whatever the level.
54
+ - **A pushed history batch tells its messenger whether it was saved.** `feed` and `listenHistory` take a
55
+ `HistoryBatchHandler`, `(batch) => void | Promise<boolean>`: the CLI's own handler resolves `true` once the batch is
56
+ in the local store and `false` when it could not be written, so an adapter can keep a batch it cannot get again
57
+ until it is stored. An adapter that ignores the answer works as before.
58
+
59
+ ### Changed — may break callers
60
+
61
+ - **`@wirecat/cli-base` 0.73.0**, for the verb `remind`. The peer range is now `>=0.73.0 <0.74.0`.
62
+
10
63
  ## 0.542.0 — 02.11.2026
11
64
 
12
65
  ### Added
@@ -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" });
@@ -2,7 +2,8 @@ import { CliError, pathsAreOverridden } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command, Option } from "commander";
4
4
  import { envName } from "../ports/app.js";
5
- import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
5
+ import { agentOf } from "../runs/run-context.js";
6
+ import { AUTHORITY_KEY, BOT_API_KEY, LEVELS, levelFor } from "../sends/permissions.js";
6
7
  import { baseContext, environmentOf } from "./context.js";
7
8
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
8
9
  import { itemOf, renderAnswer } from "./envelope.js";
@@ -115,8 +116,13 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
115
116
  // The defaults are every other profile's settings too, and the top of the file is everyone's.
116
117
  throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${root ? setting : "--defaults"} changes other profiles`);
117
118
  }
118
- if (environmentOf(this).agent)
119
+ // An agent's own shell counts too: Hermes and OpenClaw run the tool there for anyone their chat accepts (SEC-116).
120
+ if (environmentOf(this).agent || agentOf(env)) {
119
121
  refuseGuardedKey(setting, action === "set" ? String(given) : undefined, ownerOnly);
122
+ refuseLoosening(settings.permissions, setting, action === "set" ? String(given) : undefined, {
123
+ wide: Boolean(everyone || root || personal || bot),
124
+ });
125
+ }
120
126
  if (action === "set")
121
127
  refuseUnknownKey(this, setting, String(given), permissionKey);
122
128
  const saved = config.changeSetting(settings.configPath, {
@@ -185,6 +191,30 @@ const refuseGuardedKey = (setting, value, ownerOnly) => {
185
191
  return;
186
192
  throw new CliError("permission_error", `an agent cannot change permissions.${guarded} — the owner sets it in a terminal`, { permission: guarded });
187
193
  };
194
+ /**
195
+ * An agent may tighten a permission, never loosen one: a message in a chat the agent reads must not be able to talk it
196
+ * into lifting the owner's `readonly`. Removing a key, replacing `permissions` whole, or changing what several profiles
197
+ * get can loosen in ways this profile's levels cannot show, so there an agent may only set `deny`.
198
+ */
199
+ const refuseLoosening = (levels, setting, value, { wide }) => {
200
+ if (setting !== "permissions" && !setting.startsWith("permissions."))
201
+ return;
202
+ const key = setting.slice("permissions.".length);
203
+ const refuse = (why) => {
204
+ throw new CliError("permission_error", `an agent cannot loosen permissions: ${why} — the owner does it in a terminal`, {
205
+ permission: key || "permissions",
206
+ });
207
+ };
208
+ if (value === "deny" && setting !== "permissions")
209
+ return;
210
+ if (value === undefined)
211
+ refuse(`unsetting ${setting} can lift a limit`);
212
+ if (setting === "permissions" || wide)
213
+ refuse(`only deny may be set for ${setting} with --defaults, --personal, --bot or the whole object`);
214
+ const now = levelFor(levels, key).level;
215
+ if (LEVELS.indexOf(value) > LEVELS.indexOf(now))
216
+ refuse(`${setting} is ${now}, ${value} is looser`);
217
+ };
188
218
  /** The keys of a `permissions` object given whole; anything else is left to the schema to refuse. */
189
219
  const objectKeys = (value) => {
190
220
  try {
@@ -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.
@@ -31,6 +31,8 @@ import { commandWords, isProfileName, liftProfile, sampleCommand, takesProfile,
31
31
  import { absolutePaths, relayTarget } from "./relay-route.js";
32
32
  import { NoProfileHere, namedProfile, settingsFor, } from "./settings.js";
33
33
  import { assertSharedContracts } from "./shared-contracts.js";
34
+ import { skillSourceOf } from "./skill-command.js";
35
+ import { refreshSkills, skipsSkillRefresh } from "./skill-refresh.js";
34
36
  import { commandAt, failureSteps, lineOf, withLines } from "./steps.js";
35
37
  // An MCP server builds the tree on every tool call; the contracts of one definition's tree do not change between builds.
36
38
  const contractsChecked = new WeakSet();
@@ -470,6 +472,9 @@ const runOnce = async (argv, definition, options) => {
470
472
  try {
471
473
  await control.race(() => program.parseAsync(rest, { from: "user" }));
472
474
  const code = exit.code ?? (process.exitCode === undefined ? 0 : Number(process.exitCode));
475
+ const refreshed = code === 0 ? refreshFor(definition, program, rest, options) : undefined;
476
+ if (refreshed && !rest.includes("--quiet"))
477
+ streams.diagnostic(refreshed);
473
478
  const hint = code === 0 && rest[0] !== "commands" && rest[0] !== "help" && !rest.includes("--quiet")
474
479
  ? hintFor(definition, rest, options)
475
480
  : undefined;
@@ -555,6 +560,23 @@ const failingNoticeFor = (app, env, program) => {
555
560
  return undefined;
556
561
  }
557
562
  };
563
+ const NO_REFRESH_COMMANDS = ["skill", "complete", "commands", "help"];
564
+ /** Not while `skill` runs: right after an update, `skill status` must report the copies as they are. */
565
+ const refreshFor = ({ app }, program, argv, options) => {
566
+ const env = options.env ?? process.env;
567
+ try {
568
+ if (NO_REFRESH_COMMANDS.includes(argv[0] ?? "") || skipsSkillRefresh(app, env))
569
+ return undefined;
570
+ const skill = skillSourceOf(program, app.appName);
571
+ if (!skill)
572
+ return undefined;
573
+ const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state;
574
+ return refreshSkills({ app, skill, env, markerPath: join(state, "skill-refresh.json") });
575
+ }
576
+ catch {
577
+ return undefined;
578
+ }
579
+ };
558
580
  /** The update notice's state file, so both daily lines share one file per CLI. */
559
581
  const hintFor = ({ app, configuration }, argv, options) => {
560
582
  const env = options.env ?? process.env;
@@ -1,5 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { AppIdentity } from "../ports/app.js";
3
+ /** The SKILL.md a program's `skill` command installs, so a run can refresh old copies of it. */
4
+ export declare const skillSourceOf: (program: Command, appName: string) => URL | undefined;
3
5
  /**
4
6
  * `skill show` and `skill install`. `skill` is the CLI's own SKILL.md, shipped beside `dist/` in the
5
7
  * package and in a checkout alike, so the skill printed is always this version's.
@@ -3,6 +3,14 @@ import { skillCommand as sharedSkillCommand } from "@wirecat/cli-base/skill";
3
3
  import { environmentOf, outputFor } from "./context.js";
4
4
  import { grantsAuthority } from "./effects.js";
5
5
  import { printItem } from "./envelope.js";
6
+ const skillSources = new WeakMap();
7
+ // zm and memo mount their own `skill` command in place of this one (`customizeSharedCommand`), from the same file.
8
+ const skillSourcesByApp = new Map();
9
+ /** The SKILL.md a program's `skill` command installs, so a run can refresh old copies of it. */
10
+ export const skillSourceOf = (program, appName) => {
11
+ const command = program.commands.find((sub) => sub.name() === "skill");
12
+ return command ? (skillSources.get(command) ?? skillSourcesByApp.get(appName)) : undefined;
13
+ };
6
14
  /**
7
15
  * `skill show` and `skill install`. `skill` is the CLI's own SKILL.md, shipped beside `dist/` in the
8
16
  * package and in a checkout alike, so the skill printed is always this version's.
@@ -15,6 +23,8 @@ export const skillCommand = (app, skill) => {
15
23
  const renderer = { ...output.renderer, result: (value) => printItem(output, value) };
16
24
  return { ...output, renderer, ...(env ? { env } : {}) };
17
25
  }, app.messenger === false ? {} : SHARED_SKILLS);
26
+ skillSources.set(command, skill);
27
+ skillSourcesByApp.set(app.appName, skill);
18
28
  // An installed skill is instructions every later agent follows.
19
29
  const install = command.commands.find((sub) => sub.name() === "install");
20
30
  if (install) {
@@ -0,0 +1,18 @@
1
+ import { type SkillApp } from "@wirecat/cli-base/skill";
2
+ import { type AppIdentity } from "../ports/app.js";
3
+ type Env = Readonly<Record<string, string | undefined>>;
4
+ /** Not where the owner's own agents look: a test, a moved HOME or state folder, or the owner said no. */
5
+ export declare const skipsSkillRefresh: (app: AppIdentity, env: NodeJS.ProcessEnv) => boolean;
6
+ export interface SkillRefreshRequest {
7
+ app: SkillApp;
8
+ skill: URL;
9
+ env: Env;
10
+ /** `<state>/skill-refresh.json`: the version this was last tried for, so each version tries once. */
11
+ markerPath: string;
12
+ }
13
+ /**
14
+ * On the first run of a new version, rewrites the installed SKILL.md copies that an older version wrote, and answers
15
+ * the line for stderr — or `undefined` when there was nothing to do. A failure is a one-line note, never an error.
16
+ */
17
+ export declare const refreshSkills: ({ app, skill, env, markerPath }: SkillRefreshRequest) => string | undefined;
18
+ export {};
@@ -0,0 +1,82 @@
1
+ import { lstatSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { isMovedRun, loadConfigFile, saveConfigFile } from "@wirecat/cli-base";
3
+ import { hermesCopyPath, installSkill, skillStatus, skillVersion, withVersion, } from "@wirecat/cli-base/skill";
4
+ import { isNewer } from "@wirecat/cli-base/update";
5
+ import * as v from "valibot";
6
+ import { envName } from "../ports/app.js";
7
+ const marker = v.object({ version: v.optional(v.string()) });
8
+ const AGENT_NAMES = {
9
+ claude: "Claude Code",
10
+ agents: "other agents",
11
+ hermes: "Hermes",
12
+ };
13
+ /** Not where the owner's own agents look: a test, a moved HOME or state folder, or the owner said no. */
14
+ export const skipsSkillRefresh = (app, env) => env.VITEST !== undefined ||
15
+ Boolean(env[envName(app, "NO_SKILL_REFRESH")]) ||
16
+ isMovedRun({ appName: app.appName, prefix: app.envPrefix, env });
17
+ // A symlink points into a checkout or a dotfiles repo; writing through it changes a file the tool never wrote.
18
+ const isPlainFile = (path) => {
19
+ try {
20
+ return lstatSync(path).isFile();
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ };
26
+ /**
27
+ * Only `stale` copies: one with a version stamp that `skill install` wrote for an older release. `unversioned` means
28
+ * no released `skill install` wrote it (each stamps `metadata.version`), so it is somebody's own file and stays.
29
+ */
30
+ const staleTargets = (app, env) => skillStatus(app, env)
31
+ .filter(({ state, path }) => state === "stale" && isPlainFile(path))
32
+ .map(({ target }) => target);
33
+ /** Hermes's own copy, read from disk: asking Hermes would start a program and could take seconds. */
34
+ const refreshHermesCopy = (app, skill, env) => {
35
+ const path = hermesCopyPath(app.appName, env);
36
+ if (!isPlainFile(path))
37
+ return false;
38
+ const installed = skillVersion(readFileSync(path, "utf8"));
39
+ if (installed === undefined || !isNewer(app.version, installed))
40
+ return false;
41
+ writeFileSync(path, withVersion(readFileSync(skill, "utf8"), app.version));
42
+ return true;
43
+ };
44
+ /**
45
+ * On the first run of a new version, rewrites the installed SKILL.md copies that an older version wrote, and answers
46
+ * the line for stderr — or `undefined` when there was nothing to do. A failure is a one-line note, never an error.
47
+ */
48
+ export const refreshSkills = ({ app, skill, env, markerPath }) => {
49
+ try {
50
+ const seen = loadConfigFile(markerPath, marker, () => ({})).version;
51
+ if (seen === app.version)
52
+ return undefined;
53
+ }
54
+ catch {
55
+ // An unreadable marker counts as a new version; the write below replaces it.
56
+ }
57
+ const save = () => {
58
+ try {
59
+ saveConfigFile(markerPath, { version: app.version });
60
+ }
61
+ catch {
62
+ // Unsaved, the next run tries again; that costs two file reads.
63
+ }
64
+ };
65
+ try {
66
+ const targets = staleTargets(app, env);
67
+ if (targets.length > 0)
68
+ installSkill(app, skill, { targets, env });
69
+ const hermes = refreshHermesCopy(app, skill, env);
70
+ save();
71
+ const names = [...targets, ...(hermes ? ["hermes"] : [])].map((target) => AGENT_NAMES[target]);
72
+ if (names.length === 0)
73
+ return undefined;
74
+ return `Updated the ${app.command} instructions for ${listed(names)} to ${app.version}`;
75
+ }
76
+ catch (error) {
77
+ save();
78
+ const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
79
+ return `Could not update the ${app.command} instructions for agents (${reason}); \`${app.command} skill install\` does it`;
80
+ }
81
+ };
82
+ const listed = (names) => names.length < 2 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
@@ -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;
@@ -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.542.0",
3
+ "version": "0.544.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": {