@wirecat/cli-core 0.463.0 → 0.464.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/background/after-upgrade.d.ts +51 -0
  3. package/dist/background/after-upgrade.js +245 -0
  4. package/dist/background/budget.d.ts +37 -0
  5. package/dist/background/budget.js +48 -0
  6. package/dist/background/index.d.ts +1 -0
  7. package/dist/background/index.js +1 -0
  8. package/dist/calendar/busy.d.ts +68 -0
  9. package/dist/calendar/busy.js +148 -0
  10. package/dist/calendar/cli/index.d.ts +7 -0
  11. package/dist/calendar/cli/index.js +94 -12
  12. package/dist/calendar/index.d.ts +3 -2
  13. package/dist/calendar/index.js +2 -1
  14. package/dist/calendar/mcp/index.d.ts +55 -13
  15. package/dist/calendar/mcp/index.js +19 -4
  16. package/dist/calendar/recurrence.d.ts +10 -0
  17. package/dist/calendar/recurrence.js +65 -0
  18. package/dist/calendar/source.d.ts +19 -0
  19. package/dist/calendar/sync.d.ts +86 -7
  20. package/dist/calendar/sync.js +271 -105
  21. package/dist/calendar/testing/source-contract.js +14 -0
  22. package/dist/calendar/testing/source.d.ts +6 -0
  23. package/dist/calendar/testing/source.js +30 -2
  24. package/dist/calendar/testing/sync-contract.d.ts +2 -1
  25. package/dist/calendar/testing/sync-contract.js +80 -5
  26. package/dist/calendar/testing/writer-contract.d.ts +2 -1
  27. package/dist/calendar/testing/writer-contract.js +46 -2
  28. package/dist/calendar/testing/writer.js +1 -1
  29. package/dist/calendar/writer.d.ts +7 -2
  30. package/dist/calendar/writes.d.ts +12 -2
  31. package/dist/calendar/writes.js +32 -6
  32. package/dist/cli/context.d.ts +8 -0
  33. package/dist/cli/context.js +15 -0
  34. package/dist/cli/execution.d.ts +7 -0
  35. package/dist/cli/execution.js +7 -0
  36. package/dist/cli/index.d.ts +1 -1
  37. package/dist/cli/index.js +1 -1
  38. package/dist/cli/mcp-server-command.js +16 -10
  39. package/dist/cli/meeting-preparation-answer.d.ts +1 -0
  40. package/dist/cli/meeting-preparation-projects.d.ts +1 -0
  41. package/dist/cli/meeting-preparation-read.d.ts +1 -0
  42. package/dist/cli/messenger/chat-answers.js +1 -0
  43. package/dist/cli/messenger/search-answers.js +29 -4
  44. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  45. package/dist/cli/program.js +15 -5
  46. package/dist/cli/upgrade-command.d.ts +7 -2
  47. package/dist/cli/upgrade-command.js +65 -9
  48. package/dist/entities/fields.d.ts +2 -0
  49. package/dist/entities/fields.js +2 -0
  50. package/dist/entities/hit.d.ts +1 -1
  51. package/dist/entities/hit.js +3 -3
  52. package/dist/entities/index.d.ts +10 -800
  53. package/dist/entities/index.js +37 -31
  54. package/dist/entities/meeting-preparation.d.ts +1 -0
  55. package/dist/entities/topic.d.ts +1 -0
  56. package/dist/entities/topic.js +2 -1
  57. package/dist/errors/reasons.js +4 -0
  58. package/dist/mcp/http/state.d.ts +14 -0
  59. package/dist/mcp/http/state.js +16 -0
  60. package/dist/mcp/meeting-preparation.d.ts +1 -0
  61. package/dist/meetings/answers.d.ts +2 -0
  62. package/dist/meetings/answers.js +3 -0
  63. package/dist/meetings/mcp/search.d.ts +1 -0
  64. package/dist/meetings/project-evidence-answer.d.ts +1 -0
  65. package/dist/ports/app.d.ts +5 -0
  66. package/dist/services/package-upgrade.d.ts +9 -0
  67. package/dist/services/package-upgrade.js +8 -1
  68. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -7,6 +7,90 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.464.0 — 27.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`upgrade` restarts the HTTP MCP server and names the stdio MCP sessions still on the old version**, in every
15
+ CLI that uses the shared command. After a successful install it restarts each running `mcp serve --http` that
16
+ `mcp server start` launched, with the same port, address and `--permission` limits, and reports it as
17
+ `mcp_servers` (old → new version); a server started by hand, by an older version, or one it cannot confirm, is
18
+ left running and named. It lists this install's MCP sessions over stdio (`mcp serve`, and `mcp` from before it)
19
+ as `stale_sessions` (PIDs; the version is `null`), says "restart them from your agent app", and says it could not
20
+ check where it cannot list processes. Nothing is restarted or listed when nothing was installed. A serve restart
21
+ that runs out of time is now named as such, and `upgrade` no longer waits on one for more than 90 s. The restart
22
+ works from the next upgrade on: the version doing the upgrading must have it, and the server must have been
23
+ started by it.
24
+ - **Free/busy and free stretches for calendar CLIs.** `CalendarSource` gains an optional
25
+ `freeBusy({ calendars, people, window })` that answers `BusyTimes` (`{ id, busy, error? }`) and is never stored.
26
+ With `deps.freeBusy`, `addCalendarCommands` mounts `calendars busy [today|week|from:… to:…] [calendar:…]
27
+ [person:<email>…]`; `--duration-minutes <n>` adds `slots`, the stretches every calendar and person shares inside
28
+ `--hours` (default `09:00-17:00`, Monday to Friday, in the owner's zone), and `unknown`, whoever the provider would
29
+ not show. `fakeCalendarSource` answers it (`setBusy`), and `calendarSourceContract` checks it when a source has it.
30
+ - **Repeating events.** `events create --repeat <rule>` (MCP `repeat`) makes a series; `events update --series
31
+ --repeat <rule>` changes its rule and keeps its EXDATE and RDATE lines. `repeatRule` checks the RFC 5545 subset
32
+ (FREQ DAILY, WEEKLY or MONTHLY; INTERVAL; BYDAY, ordinals only in MONTHLY; COUNT or UNTIL of the event's own kind)
33
+ and writes one `RRULE:` line into `EventDraft.recurrence`. The answer of a write that sets a rule says
34
+ `hint: "sync to store its occurrences"`.
35
+ - **Switching an event between timed and all-day** is stated in `EventPatch`: dates on a timed event, or times on an
36
+ all-day one, ask the adapter to clear the other kind. A series whose UNTIL, EXDATE or RDATE holds the old kind is
37
+ refused with `event.repeat-kind`. `calendarWriterContract` checks a repeat rule on create and on the series, and
38
+ the switch both ways; `fakeCalendarWriter` returns a series for a draft with a rule.
39
+ - **A run budget for syncs, and a resumable calendar sync.** `runBudget({ requests?, deadline?, requestMs?, signal?
40
+ })` in `@wirecat/cli-core/background` answers `take()` before each request. It allows `floor((deadline − margin) /
41
+ requestMs)` requests, `margin` being the larger of 5 s and 15% of the deadline, and `requests` can only lower that;
42
+ it also says no once the deadline is closer than the margin plus the slowest request so far, or once the signal is
43
+ aborted. `AppIdentity.requestMs` is each CLI's one expected request time (2 s when unset). `runBudgetOf(command,
44
+ maxPages?)` in `./cli` builds the budget from the deadline `run()` started, which a command now reads as
45
+ `environment.deadline()`. The calendar `sync` uses it: `cooperative`, `partial`, `--max-pages <n>`; it saves after
46
+ every page and every series, and a rerun goes on from there (WC-LIFE-10, 12, 13). A run the budget stops answers
47
+ `state: "partial"`, `stopped`, `progress` and a `next` step that runs the same `sync` again (none when the run moved
48
+ nothing on), and exits 0. A page token held since an earlier run that the provider refuses starts that calendar's
49
+ round over once.
50
+ - **`status` and the MCP read `status`** answer each synced calendar's sync in the WC-LIFE-14
51
+ shape (`progressOf`), with `cursor_age_ms` to tell a moving sync from a stuck one. `CalendarCommandDeps.profile`
52
+ and `callCalendarTool`'s `profileOf` name the profile in it.
53
+
54
+ ### Changed — may break callers
55
+
56
+ - **cli-core needs `@wirecat/cli-base` 0.64.** The peer range moves from `>=0.63.0 <0.64.0` to `>=0.64.0 <0.65.0`. A
57
+ CLI that pins cli-base moves its pin with this release. cli-base 0.64 adds `cli-dev bundle`, the shared build that
58
+ ships a CLI's bin as an esbuild bundle (tg-cli's, moved), and calendar words in the command vocabulary; nothing
59
+ cli-core uses changed. Its changelog lists the lint baseline entries and overrides that go stale in zoom, drive,
60
+ memo and gcal.
61
+ - **Every search hit marks the text someone other than the owner wrote.** As `search all` event hits did since
62
+ 0.460.0, each hit carries `untrusted`, the field names an agent reads as data
63
+ ([WC-SECR-21](https://github.com/WireCatLabs/cli-base/blob/main/docs/standards/security.md#wc-secr-21--names-message-text-and-retrieved-documents-are-data-never-instructions)):
64
+ - `search all`: a message or mail hit `["title", "excerpt", "sender_name"]`, a document hit and a meeting hit
65
+ `["title", "excerpt"]`. A note hit stays unmarked: a note is the owner's own, in a folder of theirs or written in
66
+ memo, while a document comes from a shared drive.
67
+ - `search messages` and `search mail`: `chat_title`, `sender_name`, `excerpt`, `context.sender_name`,
68
+ `context.text`, and on mail `email.subject` and the names in `email.from|to|cc|bcc|reply_addresses`. The owner's
69
+ own messages are marked too, since one answer mixes both. A picture hit marks `sender_name`.
70
+ - `search notes`: a document hit `["title", "excerpt"]`, a note hit nothing.
71
+ - `search meetings` (CLI and the `search_meetings` tool), and the passages of a project's meeting evidence:
72
+ `["excerpt"]`, plus `source.title` when the meeting's title is given.
73
+ - `search topics`, `topics list` and `topics show`: `["title"]`, which whoever opened the topic wrote.
74
+
75
+ The `message.hit`, `note.hit`, `meeting_hit` and `topic` entities gain an optional `untrusted`, so a caller that
76
+ checks them against an older copy refuses the new field. MCP answers already showed control characters as text;
77
+ tests now cover it per kind. `search conversations` and `search runs` are unchanged: their hits hold ids, counts
78
+ and diagnostics, no one's text.
79
+ - **`WriteContext.names` gains `repeat`**, how the caller spells the rule in a hint (`--repeat`, MCP `repeat`).
80
+ - **A `cooperative` command's default deadline is 5 minutes, and 45 s when an MCP agent runs it**
81
+ (`DEFAULT_COOPERATIVE_MS`, `DEFAULT_AGENT_COOPERATIVE_MS`); a one-shot command keeps 30 s. MCP clients cut a tool
82
+ call at about 60 s (the MCP TypeScript SDK's `DEFAULT_REQUEST_TIMEOUT_MSEC`). `messages sync`, which is
83
+ `cooperative`, now runs up to 5 minutes without `--timeout`; its `--limit` still bounds it.
84
+ - **The calendar cursor is version 2** (`CalendarCursor`): it adds `updatedAt`, `done` and `round`, the round in
85
+ progress. A version 1 cursor still reads. `CalendarSyncResult` gains `state`; `CalendarSyncReport` gains `state`,
86
+ `stopped`, `progress`, `hint` and `next`, and `complete` is false while a round is in progress. A failed page no
87
+ longer keeps the old cursor: the pages before it stay saved and the next run goes on from the failed one.
88
+ - **`calendarSyncContract` gains cases** (a budget stop and the reruns that finish it, a near deadline, an expired
89
+ page token), and `fakeCalendarSource` page tokens change shape and gain `expirePages()`.
90
+ - **The contract kits check more.** `calendarWriterContract` gains three cases (a rule on create, a rule changed on the
91
+ series, the timed/all-day switch both ways) and `calendarSourceContract` one for a source with `freeBusy`: an
92
+ adapter that runs the kits gets tests that can fail until it supports them.
93
+
10
94
  ## 0.463.0 — 27.10.2026
11
95
 
12
96
  ### Changed — may break callers
@@ -0,0 +1,51 @@
1
+ import type { AppIdentity } from "../ports/app.js";
2
+ /** One running HTTP MCP server, and what `upgrade` did with it. */
3
+ export interface McpServerRestart {
4
+ /** What a person types before `mcp`, `tg personal`; the lock's path when nothing recorded it. */
5
+ name: string;
6
+ pid: number;
7
+ restarted: boolean;
8
+ /** The version in its lock before, and in the new one's lock after; `null` when a lock does not say. */
9
+ from: string | null;
10
+ to: string | null;
11
+ newPid?: number;
12
+ /** Why it was left as it was, or why it is not serving now. */
13
+ reason?: string;
14
+ }
15
+ /** The `mcp` sessions over stdin and stdout that an agent app started: only the app can restart them. */
16
+ export interface StaleSessions {
17
+ checked: boolean;
18
+ /** `version` is `null`: a process does not say which code it loaded. */
19
+ sessions: {
20
+ pid: number;
21
+ version: string | null;
22
+ }[];
23
+ reason?: string;
24
+ }
25
+ export interface ProcessLine {
26
+ pid: number;
27
+ /** The command line, word by word; the first is the program. */
28
+ words: string[];
29
+ }
30
+ /** What the steps after an upgrade ask of the machine. Tests hand one in. */
31
+ export interface AfterUpgradeSystem {
32
+ platform: NodeJS.Platform;
33
+ alive: (pid: number) => boolean;
34
+ carries: (pid: number, marker: string) => boolean;
35
+ stop: (pid: number) => void;
36
+ /** Starts `<cli> <argv>` from the installed package, apart from this process, and answers its PID. */
37
+ spawn: (argv: string[], env: NodeJS.ProcessEnv, log: string) => number;
38
+ pause: () => Promise<void>;
39
+ /** This user's processes; `undefined` when this system cannot list them. */
40
+ processes: () => ProcessLine[] | undefined;
41
+ /** The folder of the package this process runs from; `undefined` when it does not run from one. */
42
+ packageRoot: string | undefined;
43
+ }
44
+ /** The nearest package around `script`, when it is `packageName`: a test runner's script belongs to the runner. */
45
+ export declare const packageRootOf: (packageName: string, script: string) => string | undefined;
46
+ export declare const thisSystem: (packageName: string, command: string) => AfterUpgradeSystem;
47
+ /** Restarts each running HTTP MCP server of `app` on the code just installed, as `server restart` does for serve. */
48
+ export declare const restartMcpServers: (app: AppIdentity, env: NodeJS.ProcessEnv, system: AfterUpgradeSystem) => Promise<McpServerRestart[]>;
49
+ /** Whether a command line runs an MCP server over stdio from the package in `root`: not `--http`, not `mcp server …`. */
50
+ export declare const isStdioMcp: (line: string[], root: string, platform?: NodeJS.Platform) => boolean;
51
+ export declare const staleMcpSessions: (system: AfterUpgradeSystem) => StaleSessions;
@@ -0,0 +1,245 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, sep } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { resolvePaths } from "@wirecat/cli-base";
6
+ import { httpLaunchFile, mcpHttpMarker, readHttpLaunch, readHttpLock } from "../mcp/http/state.js";
7
+ import { alive, carries } from "./processes.js";
8
+ import { readableLog, tail } from "./units.js";
9
+ // 250 ms apart: 15 s for the old server to stop, 30 s for the new one to listen, as `mcp server start` waits.
10
+ const STOP_LOOKS = 60;
11
+ const START_LOOKS = 120;
12
+ const LOCK_DEPTH = 3;
13
+ /** The nearest package around `script`, when it is `packageName`: a test runner's script belongs to the runner. */
14
+ export const packageRootOf = (packageName, script) => {
15
+ let dir = dirname(script);
16
+ for (let up = 0; up < 8; up++) {
17
+ const manifest = join(dir, "package.json");
18
+ if (existsSync(manifest)) {
19
+ try {
20
+ return JSON.parse(readFileSync(manifest, "utf8")).name === packageName ? dir : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ const parent = dirname(dir);
27
+ if (parent === dir)
28
+ return undefined;
29
+ dir = parent;
30
+ }
31
+ return undefined;
32
+ };
33
+ /**
34
+ * The bin the new `package.json` names, read after the install: the new version may have moved its entry, and the
35
+ * old process's own path may no longer exist.
36
+ */
37
+ const binOf = (root, command) => {
38
+ try {
39
+ const { bin } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
40
+ const path = typeof bin === "string" ? bin : bin?.[command];
41
+ return path && existsSync(join(root, path)) ? join(root, path) : undefined;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ };
47
+ const words = (line) => [...line.matchAll(/"([^"]*)"|(\S+)/g)].map((match) => match[1] ?? match[2] ?? "");
48
+ const listProcesses = (platform) => {
49
+ const uid = process.getuid?.();
50
+ try {
51
+ if (platform === "linux") {
52
+ return readdirSync("/proc").flatMap((entry) => {
53
+ if (!/^\d+$/.test(entry))
54
+ return [];
55
+ try {
56
+ if (statSync(`/proc/${entry}`).uid !== uid)
57
+ return [];
58
+ const line = readFileSync(`/proc/${entry}/cmdline`, "utf8").split("\0");
59
+ if (line.at(-1) === "")
60
+ line.pop();
61
+ return [{ pid: Number(entry), words: line }];
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ });
67
+ }
68
+ if (platform === "darwin") {
69
+ const listed = execFileSync("ps", ["-axww", "-o", "pid=,uid=,command="], { encoding: "utf8", timeout: 5000 });
70
+ return listed.split("\n").flatMap((line) => {
71
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
72
+ return match && Number(match[2]) === uid ? [{ pid: Number(match[1]), words: words(match[3] ?? "") }] : [];
73
+ });
74
+ }
75
+ if (platform === "win32") {
76
+ // Without elevation Windows shows the command line of this user's processes only.
77
+ const listed = execFileSync("powershell.exe", [
78
+ "-NoProfile",
79
+ "-NonInteractive",
80
+ "-Command",
81
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='bun.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
82
+ ], { encoding: "utf8", timeout: 10_000, windowsHide: true }).trim();
83
+ if (listed === "")
84
+ return [];
85
+ const parsed = JSON.parse(listed);
86
+ return (Array.isArray(parsed) ? parsed : [parsed]).flatMap((one) => one.CommandLine ? [{ pid: one.ProcessId, words: words(one.CommandLine) }] : []);
87
+ }
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ return undefined;
93
+ };
94
+ export const thisSystem = (packageName, command) => {
95
+ const script = process.argv[1];
96
+ let real;
97
+ try {
98
+ real = script ? realpathSync(script) : undefined;
99
+ }
100
+ catch { }
101
+ const packageRoot = real ? packageRootOf(packageName, real) : undefined;
102
+ return {
103
+ platform: process.platform,
104
+ alive,
105
+ carries,
106
+ stop: (pid) => process.kill(pid, "SIGTERM"),
107
+ spawn: (argv, env, log) => {
108
+ const entry = (packageRoot && binOf(packageRoot, command)) ?? real ?? "";
109
+ mkdirSync(dirname(log), { recursive: true, mode: 0o700 });
110
+ const out = openSync(log, "a", 0o600);
111
+ try {
112
+ const child = spawn(process.execPath, [entry, ...argv], { detached: true, stdio: ["ignore", out, out], env });
113
+ child.unref();
114
+ return child.pid ?? 0;
115
+ }
116
+ finally {
117
+ closeSync(out);
118
+ }
119
+ },
120
+ pause: () => sleep(250),
121
+ processes: () => listProcesses(process.platform),
122
+ packageRoot,
123
+ };
124
+ };
125
+ /** Every HTTP lock under the state folder: each CLI lays them out its own way, a few folders deep at most. */
126
+ const httpLocks = (dir, depth = 0) => {
127
+ let entries;
128
+ try {
129
+ entries = readdirSync(dir, { withFileTypes: true });
130
+ }
131
+ catch {
132
+ return [];
133
+ }
134
+ return entries.flatMap((entry) => {
135
+ const path = join(dir, entry.name);
136
+ if (entry.isDirectory())
137
+ return depth < LOCK_DEPTH ? httpLocks(path, depth + 1) : [];
138
+ return entry.isFile() && entry.name.endsWith(".lock") && readHttpLock(path) ? [path] : [];
139
+ });
140
+ };
141
+ const errorText = (error) => error.code === "EPERM"
142
+ ? "not allowed to stop it"
143
+ : error instanceof Error
144
+ ? error.message
145
+ : String(error);
146
+ const restartOne = async (envPrefix, lockFile, lock, env, system) => {
147
+ const launch = readHttpLaunch(httpLaunchFile(lockFile));
148
+ const name = launch?.invocation ?? lockFile;
149
+ const from = lock.version ?? null;
150
+ const left = (reason, rest = {}) => ({
151
+ name,
152
+ pid: lock.pid,
153
+ restarted: false,
154
+ from,
155
+ to: null,
156
+ reason,
157
+ ...rest,
158
+ });
159
+ const marker = mcpHttpMarker(envPrefix);
160
+ const value = launch?.env[marker];
161
+ // The marker proves the PID is still the server `mcp server start` launched, not a stranger who got its number.
162
+ if (!launch || value === undefined || !system.carries(lock.pid, `${marker}=${value}`))
163
+ return left("it was not started by `mcp server start` on this version, or this system cannot confirm it was — stop it and start it again");
164
+ try {
165
+ system.stop(lock.pid);
166
+ }
167
+ catch (error) {
168
+ return left(errorText(error));
169
+ }
170
+ for (let look = 0; look < STOP_LOOKS && system.alive(lock.pid); look++)
171
+ await system.pause();
172
+ if (system.alive(lock.pid))
173
+ return left(`asked it to stop, and it still ran after 15 s — \`${name} mcp server status\``);
174
+ const log = lockFile.replace(/\.lock$/, ".log");
175
+ const { [`${envPrefix}_TIMEOUT`]: _timeout, ...inherited } = env;
176
+ let pid;
177
+ try {
178
+ pid = system.spawn(launch.argv, { ...inherited, ...launch.env }, log);
179
+ }
180
+ catch (error) {
181
+ return left(`stopped it, and could not start it again: ${errorText(error)} — \`${name} mcp server start\``);
182
+ }
183
+ for (let look = 0; look < START_LOOKS; look++) {
184
+ const now = readHttpLock(lockFile);
185
+ if (now?.pid === pid && now.listeningAt)
186
+ return { name, pid: lock.pid, restarted: true, from, to: now.version ?? null, newPid: pid };
187
+ if (!system.alive(pid)) {
188
+ const last = readableLog(tail(log, 5)).trim();
189
+ return left(`stopped it, and the new one exited before it listened${last ? `: ${last}` : ""} — \`${name} mcp server start\``, { newPid: pid });
190
+ }
191
+ await system.pause();
192
+ }
193
+ return left(`started it again (PID ${pid}), and it was not listening after 30 s — \`${name} mcp server status\``, {
194
+ newPid: pid,
195
+ });
196
+ };
197
+ /** Restarts each running HTTP MCP server of `app` on the code just installed, as `server restart` does for serve. */
198
+ export const restartMcpServers = async (app, env, system) => {
199
+ const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state;
200
+ const done = [];
201
+ for (const lockFile of httpLocks(state)) {
202
+ const lock = readHttpLock(lockFile);
203
+ if (lock && system.alive(lock.pid))
204
+ done.push(await restartOne(app.envPrefix, lockFile, lock, env, system));
205
+ }
206
+ return done;
207
+ };
208
+ // The `mcp` subcommands that are not a server; `mcp` alone was the server before `mcp serve`.
209
+ const NOT_SERVING = new Set(["server", "config", "add", "check", "revoke"]);
210
+ /** Whether a command line runs an MCP server over stdio from the package in `root`: not `--http`, not `mcp server …`. */
211
+ export const isStdioMcp = (line, root, platform = process.platform) => {
212
+ const at = line.findIndex((word, index) => index > 0 && !word.startsWith("-"));
213
+ const script = line[at];
214
+ if (at < 0 || script === undefined || !isAbsolute(script))
215
+ return false;
216
+ let path = script;
217
+ try {
218
+ path = realpathSync(script);
219
+ }
220
+ catch { }
221
+ const fold = (text) => (platform === "win32" ? text.toLowerCase() : text);
222
+ if (!fold(path).startsWith(fold(root + sep)) && !fold(script).startsWith(fold(root + sep)))
223
+ return false;
224
+ const rest = line.slice(at + 1);
225
+ if (rest.includes("--http"))
226
+ return false;
227
+ const plain = rest.filter((word) => !word.startsWith("-"));
228
+ const mcp = plain.indexOf("mcp");
229
+ const leads = plain.slice(0, Math.max(mcp, 0)).filter((word) => word !== "bot");
230
+ return mcp >= 0 && leads.length <= 1 && !NOT_SERVING.has(plain[mcp + 1] ?? "");
231
+ };
232
+ export const staleMcpSessions = (system) => {
233
+ const root = system.packageRoot;
234
+ if (root === undefined)
235
+ return { checked: false, sessions: [], reason: "this process does not run from an installed package" };
236
+ const lines = system.processes();
237
+ if (lines === undefined)
238
+ return { checked: false, sessions: [], reason: `could not list the processes on ${system.platform}` };
239
+ return {
240
+ checked: true,
241
+ sessions: lines
242
+ .filter((line) => line.pid !== process.pid && isStdioMcp(line.words, root, system.platform))
243
+ .map((line) => ({ pid: line.pid, version: null })),
244
+ };
245
+ };
@@ -0,0 +1,37 @@
1
+ /** Why a budgeted run stopped asking: its request count is spent, its deadline is close, or it was cancelled. */
2
+ export type BudgetStop = "requests" | "deadline" | "cancelled";
3
+ /** The command's hard limit as `run()` started it: when it ends, and how long it was. */
4
+ export interface Deadline {
5
+ endsAt: number;
6
+ ms: number;
7
+ }
8
+ /**
9
+ * A sync asks it before each request to the source, so the run stops on its own, saves where it is, and says
10
+ * "run again to continue" instead of being cut by its deadline.
11
+ */
12
+ export interface RunBudget {
13
+ /** One more request: `null` when it may go, otherwise why not. Once it says no it keeps saying no. */
14
+ take(): BudgetStop | null;
15
+ readonly used: number;
16
+ /** `null`: no request limit, only the deadline. */
17
+ readonly limit: number | null;
18
+ readonly stopped: BudgetStop | null;
19
+ }
20
+ export declare const DEADLINE_MARGIN_MS = 5000;
21
+ export declare const DEADLINE_MARGIN_SHARE = 0.15;
22
+ /** How long one request to a provider is expected to take when the CLI does not say (`AppIdentity.requestMs`). */
23
+ export declare const DEFAULT_REQUEST_MS = 2000;
24
+ export declare const deadlineMargin: (deadline: Deadline) => number;
25
+ /** The requests that fit before the margin: `(deadline − margin) / requestMs`, at least one; none without a deadline. */
26
+ export declare const requestsFor: (deadline: Deadline | undefined, requestMs?: number) => number | null;
27
+ /**
28
+ * Stops at whichever comes first: the requests that fit the deadline (`requestsFor`), an explicit `requests` lower
29
+ * than that, the deadline's margin plus the slowest request so far, or the signal.
30
+ */
31
+ export declare const runBudget: ({ requests, deadline, requestMs, signal, now, }?: {
32
+ requests?: number;
33
+ deadline?: Deadline;
34
+ requestMs?: number;
35
+ signal?: AbortSignal;
36
+ now?: () => number;
37
+ }) => RunBudget;
@@ -0,0 +1,48 @@
1
+ export const DEADLINE_MARGIN_MS = 5_000;
2
+ export const DEADLINE_MARGIN_SHARE = 0.15;
3
+ /** How long one request to a provider is expected to take when the CLI does not say (`AppIdentity.requestMs`). */
4
+ export const DEFAULT_REQUEST_MS = 2_000;
5
+ export const deadlineMargin = (deadline) => Math.max(DEADLINE_MARGIN_MS, deadline.ms * DEADLINE_MARGIN_SHARE);
6
+ /** The requests that fit before the margin: `(deadline − margin) / requestMs`, at least one; none without a deadline. */
7
+ export const requestsFor = (deadline, requestMs = DEFAULT_REQUEST_MS) => deadline === undefined ? null : Math.max(1, Math.floor((deadline.ms - deadlineMargin(deadline)) / requestMs));
8
+ /**
9
+ * Stops at whichever comes first: the requests that fit the deadline (`requestsFor`), an explicit `requests` lower
10
+ * than that, the deadline's margin plus the slowest request so far, or the signal.
11
+ */
12
+ export const runBudget = ({ requests, deadline, requestMs, signal, now = Date.now, } = {}) => {
13
+ const fits = requestsFor(deadline, requestMs);
14
+ const limit = fits === null ? (requests ?? null) : Math.min(fits, requests ?? fits);
15
+ const margin = deadline ? deadlineMargin(deadline) : 0;
16
+ let used = 0;
17
+ let stopped = null;
18
+ let last;
19
+ // A request may page inside the source, so the gap between two asks is the honest measure of one.
20
+ let slowest = 0;
21
+ return {
22
+ take() {
23
+ if (stopped)
24
+ return stopped;
25
+ const at = now();
26
+ if (last !== undefined)
27
+ slowest = Math.max(slowest, at - last);
28
+ if (signal?.aborted)
29
+ stopped = "cancelled";
30
+ else if (limit !== null && used >= limit)
31
+ stopped = "requests";
32
+ else if (deadline && at + margin + slowest >= deadline.endsAt)
33
+ stopped = "deadline";
34
+ if (stopped)
35
+ return stopped;
36
+ used += 1;
37
+ last = at;
38
+ return null;
39
+ },
40
+ get used() {
41
+ return used;
42
+ },
43
+ limit,
44
+ get stopped() {
45
+ return stopped;
46
+ },
47
+ };
48
+ };
@@ -1,3 +1,4 @@
1
+ export { type BudgetStop, DEADLINE_MARGIN_MS, DEADLINE_MARGIN_SHARE, DEFAULT_REQUEST_MS, type Deadline, deadlineMargin, type RunBudget, requestsFor, runBudget, } from "./budget.js";
1
2
  export { type Checkpoint, checkpointPath, clearCheckpoint, type Progress, progressLine, progressOf, readCheckpoint, saveCheckpoint, } from "./checkpoints.js";
2
3
  export type { DailyArchiveTimer, DailyArchiveTimerInput } from "./daily-archive-timer.js";
3
4
  export { dailyArchiveTimer, manageDailyArchiveTimer } from "./daily-archive-timer.js";
@@ -1,3 +1,4 @@
1
+ export { DEADLINE_MARGIN_MS, DEADLINE_MARGIN_SHARE, DEFAULT_REQUEST_MS, deadlineMargin, requestsFor, runBudget, } from "./budget.js";
1
2
  export { checkpointPath, clearCheckpoint, progressLine, progressOf, readCheckpoint, saveCheckpoint, } from "./checkpoints.js";
2
3
  export { dailyArchiveTimer, manageDailyArchiveTimer } from "./daily-archive-timer.js";
3
4
  export { holdLock, lockPath, readLock, releaseLock, servingProfiles, takeLock } from "./lock.js";
@@ -0,0 +1,68 @@
1
+ import type { EventStore, StoredCalendar } from "../meetings/index.js";
2
+ import type { CalendarSource, SyncWindow } from "./source.js";
3
+ export declare const BUSY_QUERY_HELP = "\nWords, any order:\n today | week today, or today and the next six days, in your time zone\n from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)\n calendar:<id> one of the account's calendars, by its id or the provider's id; repeat for more\n person:<email> someone else, by email address; repeat for more\nWithout a time word: this week. Without calendar:, every calendar that syncs.";
4
+ export interface BusyQuery {
5
+ window: SyncWindow;
6
+ calendars: StoredCalendar[];
7
+ people: string[];
8
+ }
9
+ export declare const busyQuery: (words: readonly string[], context: {
10
+ store: EventStore;
11
+ accountId: number;
12
+ zone: string;
13
+ now: number;
14
+ }) => Promise<BusyQuery>;
15
+ export interface WorkingHours {
16
+ /** Minutes after local midnight. */
17
+ start: number;
18
+ end: number;
19
+ }
20
+ export declare const workingHoursOf: (text: string) => WorkingHours;
21
+ /**
22
+ * The stretches of at least `minutes` inside working hours on weekdays, after `now`, that no busy time touches.
23
+ * A stretch is returned whole; the caller picks where in it to put the meeting.
24
+ */
25
+ export declare const freeSlots: (busy: readonly SyncWindow[], window: SyncWindow, options: {
26
+ minutes: number;
27
+ hours: WorkingHours;
28
+ zone: string;
29
+ now: number;
30
+ }) => SyncWindow[];
31
+ /**
32
+ * `calendars busy`: the provider's busy times for the query, and with `slot` the free stretches every calendar and
33
+ * person shares. Nothing is stored, and a person appears only by the address the caller gave.
34
+ */
35
+ export declare const busyAnswer: (source: CalendarSource, query: BusyQuery, options: {
36
+ zone: string;
37
+ now: number;
38
+ slot?: {
39
+ minutes: number;
40
+ hours: WorkingHours;
41
+ };
42
+ }) => Promise<{
43
+ start: string;
44
+ end: string;
45
+ timezone: string;
46
+ calendars: ({
47
+ id: string;
48
+ calendar: string;
49
+ busy: {
50
+ start: string;
51
+ end: string;
52
+ }[];
53
+ error?: string | undefined;
54
+ } | {
55
+ id: string;
56
+ person: string;
57
+ busy: {
58
+ start: string;
59
+ end: string;
60
+ }[];
61
+ error?: string | undefined;
62
+ })[];
63
+ slots?: {
64
+ start: string;
65
+ end: string;
66
+ }[] | undefined;
67
+ unknown?: string[] | undefined;
68
+ }>;