@wirecat/cli-core 0.462.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.
- package/CHANGELOG.md +106 -0
- package/dist/background/after-upgrade.d.ts +51 -0
- package/dist/background/after-upgrade.js +245 -0
- package/dist/background/budget.d.ts +37 -0
- package/dist/background/budget.js +48 -0
- package/dist/background/index.d.ts +1 -0
- package/dist/background/index.js +1 -0
- package/dist/calendar/busy.d.ts +68 -0
- package/dist/calendar/busy.js +148 -0
- package/dist/calendar/cli/index.d.ts +7 -0
- package/dist/calendar/cli/index.js +94 -12
- package/dist/calendar/index.d.ts +3 -2
- package/dist/calendar/index.js +2 -1
- package/dist/calendar/mcp/index.d.ts +55 -13
- package/dist/calendar/mcp/index.js +19 -4
- package/dist/calendar/recurrence.d.ts +10 -0
- package/dist/calendar/recurrence.js +65 -0
- package/dist/calendar/source.d.ts +19 -0
- package/dist/calendar/sync.d.ts +86 -7
- package/dist/calendar/sync.js +271 -105
- package/dist/calendar/testing/source-contract.js +14 -0
- package/dist/calendar/testing/source.d.ts +6 -0
- package/dist/calendar/testing/source.js +30 -2
- package/dist/calendar/testing/sync-contract.d.ts +2 -1
- package/dist/calendar/testing/sync-contract.js +80 -5
- package/dist/calendar/testing/writer-contract.d.ts +2 -1
- package/dist/calendar/testing/writer-contract.js +46 -2
- package/dist/calendar/testing/writer.js +1 -1
- package/dist/calendar/writer.d.ts +7 -2
- package/dist/calendar/writes.d.ts +12 -2
- package/dist/calendar/writes.js +32 -6
- package/dist/cli/bot/mcp.js +11 -3
- package/dist/cli/context.d.ts +8 -0
- package/dist/cli/context.js +15 -0
- package/dist/cli/execution.d.ts +7 -0
- package/dist/cli/execution.js +7 -0
- package/dist/cli/guide.js +1 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/mcp-server-command.d.ts +9 -4
- package/dist/cli/mcp-server-command.js +32 -25
- package/dist/cli/meeting-preparation-answer.d.ts +1 -0
- package/dist/cli/meeting-preparation-projects.d.ts +1 -0
- package/dist/cli/meeting-preparation-read.d.ts +1 -0
- package/dist/cli/messenger/chat-answers.js +1 -0
- package/dist/cli/messenger/mcp-command.d.ts +3 -4
- package/dist/cli/messenger/mcp-command.js +42 -26
- package/dist/cli/messenger/search-answers.js +29 -4
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/program.js +15 -5
- package/dist/cli/upgrade-command.d.ts +7 -2
- package/dist/cli/upgrade-command.js +65 -9
- package/dist/entities/fields.d.ts +2 -0
- package/dist/entities/fields.js +2 -0
- package/dist/entities/hit.d.ts +1 -1
- package/dist/entities/hit.js +3 -3
- package/dist/entities/index.d.ts +10 -800
- package/dist/entities/index.js +37 -31
- package/dist/entities/meeting-preparation.d.ts +1 -0
- package/dist/entities/topic.d.ts +1 -0
- package/dist/entities/topic.js +2 -1
- package/dist/errors/reasons.js +4 -0
- package/dist/kit/mcp-stdio.d.ts +2 -2
- package/dist/kit/mcp-stdio.js +2 -2
- package/dist/mcp/http/activity.d.ts +1 -1
- package/dist/mcp/http/activity.js +1 -1
- package/dist/mcp/http/oauth.js +1 -1
- package/dist/mcp/http/state.d.ts +14 -0
- package/dist/mcp/http/state.js +16 -0
- package/dist/mcp/meeting-preparation.d.ts +1 -0
- package/dist/meetings/answers.d.ts +2 -0
- package/dist/meetings/answers.js +3 -0
- package/dist/meetings/mcp/search.d.ts +1 -0
- package/dist/meetings/project-evidence-answer.d.ts +1 -0
- package/dist/ports/app.d.ts +5 -0
- package/dist/services/package-upgrade.d.ts +9 -0
- package/dist/services/package-upgrade.js +8 -1
- package/package.json +3 -3
- package/parity.json +30 -22
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,112 @@ 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
|
+
|
|
94
|
+
## 0.463.0 — 27.10.2026
|
|
95
|
+
|
|
96
|
+
### Changed — may break callers
|
|
97
|
+
|
|
98
|
+
- **The MCP server is `mcp serve`; `mcp` alone only shows its commands**
|
|
99
|
+
([WC-CMD-3](https://github.com/WireCatLabs/cli-base/blob/main/docs/standards/commands.md#wc-cmd-3--give-a-group-without-a-leaf-only-its-help-never-a-hidden-default-action)).
|
|
100
|
+
`mcpCommand` gives the `mcp` group with `serve`, `revoke`, `config`, `add`, `check` and `server`; `--http`, `--port`,
|
|
101
|
+
`--public-url` and `--permission` move from `mcp` to `mcp serve`, and `mcp --revoke` is now its own command,
|
|
102
|
+
`mcp revoke`, with no flag left behind. An agent is set up with
|
|
103
|
+
`claude mcp add <cli> -- <cli> <profile> mcp serve`. `bot mcp` is a group too, and the bot's server is
|
|
104
|
+
`bot mcp serve`. `serverEntry`, so `mcp config`, `mcp add`, `mcp check` and `bot mcp config`, writes `mcp serve`,
|
|
105
|
+
the server of every profile starts `<cli> <profile> mcp serve` for each, and `mcp server start` runs
|
|
106
|
+
`mcp serve --http`. There is no alias: an old client entry that runs `<cli> <profile> mcp` gets the group's help and
|
|
107
|
+
exits 2 — print it again with `mcp config` or `mcp add`.
|
|
108
|
+
- **`mcpServerCommand` takes `--permission` only when asked.** `mcp server start` offered `--permission` to
|
|
109
|
+
every CLI and passed it to a server that might not take it; that server died before it listened and `start`
|
|
110
|
+
answered `provider_unavailable` with `retry.safe: true`, so an agent retried for ever. A CLI whose `mcp serve` takes
|
|
111
|
+
`--permission` passes `permission: true` (`mcpCommand` does); otherwise `start --permission` is refused as an unknown
|
|
112
|
+
option before anything starts. A `--permission` set on the CLI's root is still handed over. A server that stops
|
|
113
|
+
before it listens is now `retry.safe: false`. A CLI's own target names its server as `argv: [..., "mcp", "serve",
|
|
114
|
+
"--http"]` once its `mcp` is a group too.
|
|
115
|
+
|
|
10
116
|
## 0.462.0 — 27.10.2026
|
|
11
117
|
|
|
12
118
|
### 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";
|
package/dist/background/index.js
CHANGED
|
@@ -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
|
+
}>;
|