@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +106 -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/bot/mcp.js +11 -3
  33. package/dist/cli/context.d.ts +8 -0
  34. package/dist/cli/context.js +15 -0
  35. package/dist/cli/execution.d.ts +7 -0
  36. package/dist/cli/execution.js +7 -0
  37. package/dist/cli/guide.js +1 -1
  38. package/dist/cli/index.d.ts +1 -1
  39. package/dist/cli/index.js +1 -1
  40. package/dist/cli/mcp-server-command.d.ts +9 -4
  41. package/dist/cli/mcp-server-command.js +32 -25
  42. package/dist/cli/meeting-preparation-answer.d.ts +1 -0
  43. package/dist/cli/meeting-preparation-projects.d.ts +1 -0
  44. package/dist/cli/meeting-preparation-read.d.ts +1 -0
  45. package/dist/cli/messenger/chat-answers.js +1 -0
  46. package/dist/cli/messenger/mcp-command.d.ts +3 -4
  47. package/dist/cli/messenger/mcp-command.js +42 -26
  48. package/dist/cli/messenger/search-answers.js +29 -4
  49. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  50. package/dist/cli/program.js +15 -5
  51. package/dist/cli/upgrade-command.d.ts +7 -2
  52. package/dist/cli/upgrade-command.js +65 -9
  53. package/dist/entities/fields.d.ts +2 -0
  54. package/dist/entities/fields.js +2 -0
  55. package/dist/entities/hit.d.ts +1 -1
  56. package/dist/entities/hit.js +3 -3
  57. package/dist/entities/index.d.ts +10 -800
  58. package/dist/entities/index.js +37 -31
  59. package/dist/entities/meeting-preparation.d.ts +1 -0
  60. package/dist/entities/topic.d.ts +1 -0
  61. package/dist/entities/topic.js +2 -1
  62. package/dist/errors/reasons.js +4 -0
  63. package/dist/kit/mcp-stdio.d.ts +2 -2
  64. package/dist/kit/mcp-stdio.js +2 -2
  65. package/dist/mcp/http/activity.d.ts +1 -1
  66. package/dist/mcp/http/activity.js +1 -1
  67. package/dist/mcp/http/oauth.js +1 -1
  68. package/dist/mcp/http/state.d.ts +14 -0
  69. package/dist/mcp/http/state.js +16 -0
  70. package/dist/mcp/meeting-preparation.d.ts +1 -0
  71. package/dist/meetings/answers.d.ts +2 -0
  72. package/dist/meetings/answers.js +3 -0
  73. package/dist/meetings/mcp/search.d.ts +1 -0
  74. package/dist/meetings/project-evidence-answer.d.ts +1 -0
  75. package/dist/ports/app.d.ts +5 -0
  76. package/dist/services/package-upgrade.d.ts +9 -0
  77. package/dist/services/package-upgrade.js +8 -1
  78. package/package.json +3 -3
  79. package/parity.json +30 -22
@@ -19,31 +19,36 @@ import { nextStep } from "../steps.js";
19
19
  import { recalledAccount } from "./accounts.js";
20
20
  import { messengerContext } from "./context.js";
21
21
  const DEFAULT_PORT = MCP_HTTP_DEFAULT_PORT;
22
- /** The token file of `<tool> mcp --http` for every profile: no profile name can start with `_`. */
22
+ /** The token file of `<tool> mcp serve --http` for every profile: no profile name can start with `_`. */
23
23
  export const ALL_PROFILES = "_all";
24
- /** Where `mcp --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
24
+ /** Where `mcp serve --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
25
25
  export const httpTokenFile = (app, profile, env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "mcp-http", `${profile}.json`);
26
26
  const withFlags = (command) => command.option("--permission <key=level>", "make a permission stricter for this server only, like messages.send=deny; it never loosens the profile; repeat for more keys", (value, previous = []) => [...previous, value]);
27
27
  export const mcpCommand = (messenger) => {
28
28
  const { app } = messenger;
29
- const command = withFlags(annotate(new Command("mcp"), { effects: REMOTE_WRITE, lifetime: "persistent", mcp: false })
29
+ const command = annotate(new Command("mcp"), { effects: LOCAL_READ, mcp: false })
30
+ .summary("serve this profile's commands to an AI agent, and set that up")
31
+ .description(`serve this profile to an agent over MCP and set it up — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp serve\``);
32
+ command.addCommand(withFlags(annotate(new Command("serve"), {
33
+ effects: REMOTE_WRITE,
34
+ lifetime: "persistent",
35
+ mcp: false,
36
+ examples: [
37
+ `${app.command} personal mcp serve`,
38
+ `${app.command} personal mcp serve --http --public-url https://laptop.example.ts.net`,
39
+ ],
40
+ })
30
41
  .summary("serve this profile's commands to an AI agent")
31
- .description(`serve this profile to an agent over MCP, on stdin and stdout — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp\`; with no profile, \`${app.command} mcp\` serves every profile and each tool asks which`))
42
+ .description(`serve this profile to an agent over MCP, on stdin and stdout — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp serve\`; with no profile, \`${app.command} mcp serve\` serves every profile and each tool asks which`))
32
43
  .option("--http", "serve over HTTP on 127.0.0.1 for ChatGPT and Claude in the browser, behind your tunnel; the profile's permissions decide")
33
44
  .option("--port <port>", `the local port for --http (default ${DEFAULT_PORT})`)
34
45
  .option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net or https://<name>.ts.net/tg")
35
- .option("--revoke", "forget every login given to a browser app; each must log in again");
36
- command.action(async function () {
46
+ .action(async function () {
37
47
  const flags = this.optsWithGlobals();
38
48
  const context = messengerContext(this, messenger, { needsProfile: false });
39
- if (namedProfile(context.settings) === undefined)
49
+ const profile = namedProfile(context.settings);
50
+ if (profile === undefined)
40
51
  return serveEveryProfile(this, context, messenger, flags);
41
- if (flags.revoke) {
42
- const { revokeAll } = await import("../../mcp/http/oauth.js");
43
- revokeAll(httpTokenFile(app, context.settings.profile, context.env));
44
- renderAnswer(this, context, itemOf({ revoked: true, profile: context.settings.profile }));
45
- return;
46
- }
47
52
  if (flags.http) {
48
53
  const publicUrl = publicUrlOf(flags.publicUrl, {
49
54
  missing: "--http",
@@ -51,7 +56,7 @@ export const mcpCommand = (messenger) => {
51
56
  path: true,
52
57
  });
53
58
  const { serveOverHttpUntilStopped } = await import("../../mcp/server.js");
54
- const tokenFile = httpTokenFile(app, context.settings.profile, context.env);
59
+ const tokenFile = httpTokenFile(app, profile, context.env);
55
60
  await serveOverHttpUntilStopped(this, context, messenger, {}, {
56
61
  publicUrl,
57
62
  port: portOf(flags.port, DEFAULT_PORT),
@@ -64,9 +69,24 @@ export const mcpCommand = (messenger) => {
64
69
  // Loaded here, not at the top: every other command would otherwise pay for the SDK.
65
70
  const { serveOverStdio } = await import("../../mcp/server.js");
66
71
  await serveOverStdio(this, context, messenger, {});
67
- });
72
+ }));
73
+ command.addCommand(annotate(new Command("revoke"), {
74
+ effects: LOCAL_WRITE,
75
+ mcp: false,
76
+ examples: [`${app.command} personal mcp revoke`],
77
+ })
78
+ .summary("forget every login given to a browser app")
79
+ .description(`forget every login \`mcp serve --http\` gave a browser app, of this profile or, with no profile, of the server of every profile; each app must log in again`)
80
+ .action(async function () {
81
+ const context = messengerContext(this, messenger, { needsProfile: false });
82
+ const profile = namedProfile(context.settings);
83
+ const { revokeAll } = await import("../../mcp/http/oauth.js");
84
+ revokeAll(httpTokenFile(app, profile ?? ALL_PROFILES, context.env));
85
+ renderAnswer(this, context, itemOf({ revoked: true, profile: profile ?? null }));
86
+ }));
68
87
  command.addCommand(mcpServerCommand({
69
88
  example: `${app.command} personal`,
89
+ permission: true,
70
90
  target: (self) => {
71
91
  const { settings, env } = messengerContext(self, messenger, { needsProfile: false });
72
92
  if (namedProfile(settings) === undefined)
@@ -75,7 +95,7 @@ export const mcpCommand = (messenger) => {
75
95
  envPrefix: app.envPrefix,
76
96
  env,
77
97
  tokenFile: httpTokenFile(app, ALL_PROFILES, env),
78
- argv: ["mcp", "--http"],
98
+ argv: ["mcp", "serve", "--http"],
79
99
  version: app.version,
80
100
  };
81
101
  return {
@@ -84,7 +104,7 @@ export const mcpCommand = (messenger) => {
84
104
  envPrefix: app.envPrefix,
85
105
  env,
86
106
  tokenFile: httpTokenFile(app, settings.profile, env),
87
- argv: ["mcp", "--http"],
107
+ argv: ["mcp", "serve", "--http"],
88
108
  childEnv: { [`${app.envPrefix}_PROFILE`]: settings.profile },
89
109
  version: app.version,
90
110
  };
@@ -194,22 +214,17 @@ export const mcpCommand = (messenger) => {
194
214
  return command;
195
215
  };
196
216
  /**
197
- * `<tool> mcp` with no profile: every profile on this computer, each call in that profile's own `<tool> <profile> mcp`
217
+ * `<tool> mcp serve` with no profile: every profile on this computer, each call in that profile's own
218
+ * `<tool> <profile> mcp serve`
198
219
  * (`src/mcp/all-profiles.ts`). Nothing picks a profile: each tool takes one, and the agent is told to ask.
199
220
  */
200
221
  const serveEveryProfile = async (self, context, messenger, flags) => {
201
222
  const { app } = messenger;
202
223
  const { env } = context;
203
224
  const tokenFile = httpTokenFile(app, ALL_PROFILES, env);
204
- if (flags.revoke) {
205
- const { revokeAll } = await import("../../mcp/http/oauth.js");
206
- revokeAll(tokenFile);
207
- renderAnswer(self, context, itemOf({ revoked: true, profile: null }));
208
- return;
209
- }
210
225
  const profiles = () => messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true }).profilesHere;
211
226
  if (profiles().length === 0)
212
- throw new CliError("configuration_error", `no ${app.command} profile on this computer yet — set one up, then run \`${app.command} mcp\` again`);
227
+ throw new CliError("configuration_error", `no ${app.command} profile on this computer yet — set one up, then run \`${app.command} mcp serve\` again`);
213
228
  const given = environmentOf(self).mcp ?? {};
214
229
  const execPath = given.execPath ?? process.execPath;
215
230
  const scriptPath = given.scriptPath ?? realpathSync(process.argv[1] ?? "");
@@ -220,7 +235,7 @@ const serveEveryProfile = async (self, context, messenger, flags) => {
220
235
  version: app.version,
221
236
  name: messenger.name ?? app.command,
222
237
  profiles,
223
- child: (profile) => ({ command: execPath, args: [scriptPath, profile, "mcp", ...tightened], env }),
238
+ child: (profile) => ({ command: execPath, args: [scriptPath, profile, "mcp", "serve", ...tightened], env }),
224
239
  instructions: instructions({
225
240
  command: app.command,
226
241
  name: messenger.name ?? app.command,
@@ -337,6 +352,7 @@ export const serverEntry = (app, { profile, flags = {}, execPath, scriptPath, en
337
352
  scriptPath,
338
353
  ...(profile === undefined ? [] : [profile]),
339
354
  "mcp",
355
+ "serve",
340
356
  ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
341
357
  ],
342
358
  ...(Object.keys(directories).length > 0 ? { env: directories } : {}),
@@ -10,6 +10,16 @@ export const SOURCE_SEARCHED = ["messages", "notes", "events", "meetings"];
10
10
  const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, one]) => one !== undefined));
11
11
  /** The page a `--page` asks for, as the offset the search services take. */
12
12
  export const offsetOf = (page, limit) => page === undefined || page <= 1 ? undefined : (page - 1) * limit;
13
+ /** Who wrote it and what, the chat's title, the mail's headers: others write them; the owner's own messages too. */
14
+ const MESSAGE_HIT_UNTRUSTED = ["chat_title", "sender_name", "excerpt", "context.sender_name", "context.text"];
15
+ const MAIL_HIT_UNTRUSTED = [
16
+ "email.subject",
17
+ "email.from.name",
18
+ "email.to.name",
19
+ "email.cc.name",
20
+ "email.bcc.name",
21
+ "email.reply_addresses.name",
22
+ ];
13
23
  export const messageHitItem = (hit, query = "") => {
14
24
  const at = parseLocator(hit.locator);
15
25
  const around = hit.thread?.items ?? hit.context;
@@ -55,6 +65,7 @@ export const messageHitItem = (hit, query = "") => {
55
65
  sender_name: one.senderName,
56
66
  text: one.text,
57
67
  })),
68
+ untrusted: hit.email ? [...MESSAGE_HIT_UNTRUSTED, ...MAIL_HIT_UNTRUSTED] : MESSAGE_HIT_UNTRUSTED,
58
69
  });
59
70
  };
60
71
  const accountName = ({ provider, account }) => `${provider}:${account}`;
@@ -160,6 +171,7 @@ const pictureItems = (found) => {
160
171
  sender_name: senderName,
161
172
  matched_in: "picture",
162
173
  score,
174
+ untrusted: ["sender_name"],
163
175
  }));
164
176
  };
165
177
  /** `search messages` and `search mail`, CLI and MCP alike. */
@@ -175,9 +187,21 @@ export const messagesAnswer = (command, found, { limit, page, again }, query = "
175
187
  meta: metaOf(accounts, found.skipped),
176
188
  });
177
189
  };
178
- export const noteHitItem = (hit) => defined({ ...hit });
179
- /** An event's title and the text its excerpt is cut from come from whoever sent the invitation. */
180
- const EVENT_HIT_UNTRUSTED = ["title", "excerpt"];
190
+ /**
191
+ * A document comes from a shared drive, where others write and edit; a note is the owner's own, in a folder of
192
+ * theirs or written in memo, so it is left unmarked.
193
+ */
194
+ const DOCUMENT_HIT_UNTRUSTED = ["title", "excerpt"];
195
+ export const noteHitItem = (hit) => defined({ ...hit, untrusted: hit.kind === "document" ? DOCUMENT_HIT_UNTRUSTED : undefined });
196
+ /** The text of each kind of `search all` hit that someone other than the owner may have written. */
197
+ const ALL_HIT_UNTRUSTED = {
198
+ message: ["title", "excerpt", "sender_name"],
199
+ mail: ["title", "excerpt", "sender_name"],
200
+ document: DOCUMENT_HIT_UNTRUSTED,
201
+ note: undefined,
202
+ event: ["title", "excerpt"],
203
+ meeting: ["title", "excerpt"],
204
+ };
181
205
  export const allHitItem = (item) => item.kind === "meeting"
182
206
  ? {
183
207
  kind: "meeting",
@@ -190,6 +214,7 @@ export const allHitItem = (item) => item.kind === "meeting"
190
214
  offset_ms: item.startMs,
191
215
  scope: item.scope,
192
216
  id: String(item.id),
217
+ untrusted: ALL_HIT_UNTRUSTED.meeting,
193
218
  }
194
219
  : defined({
195
220
  kind: item.kind,
@@ -203,7 +228,7 @@ export const allHitItem = (item) => item.kind === "meeting"
203
228
  sender_name: item.from?.name,
204
229
  sender_ref: item.from?.ref,
205
230
  found_by: item.foundBy,
206
- untrusted: item.kind === "event" ? EVENT_HIT_UNTRUSTED : undefined,
231
+ untrusted: ALL_HIT_UNTRUSTED[item.kind],
207
232
  });
208
233
  /** Meaning was not searched because the model is not downloaded; `--exact` leaving it out is no gap. */
209
234
  const meaningGap = (skipped, exact) => skipped === undefined || exact
@@ -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;
@@ -20,7 +20,7 @@ import { outputFor, provide } from "./context.js";
20
20
  import { refuseByCommandPermissions, refuseWriteBeforeConnecting } from "./early-refusal.js";
21
21
  import { changesAuthorityAt, changesAuthorityNow, isLogin, isStoreChange, judgedEffects, judgedTargets, storeChangeRefusal, } from "./effects.js";
22
22
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
23
- import { byteCount, DEFAULT_COMMAND_MS, DEFAULT_OUTPUT_BYTES, execution } from "./execution.js";
23
+ import { byteCount, DEFAULT_AGENT_COOPERATIVE_MS, DEFAULT_COMMAND_MS, DEFAULT_COOPERATIVE_MS, DEFAULT_OUTPUT_BYTES, execution, } from "./execution.js";
24
24
  import { setupAtStart } from "./first-run.js";
25
25
  import { installHelp } from "./help/first-screen.js";
26
26
  import { showInputFields } from "./input-fields.js";
@@ -150,6 +150,11 @@ const runOnce = async (argv, definition, options) => {
150
150
  let oneShot;
151
151
  // Headless, an interactive command's 5 minutes are for its phase; a person at a terminal may still be answering.
152
152
  let afterPhase;
153
+ let running;
154
+ const startDeadline = (ms) => {
155
+ running = ms === undefined ? undefined : { endsAt: Date.now() + ms, ms };
156
+ control.start(ms);
157
+ };
153
158
  let finished = false;
154
159
  // The steps after a phase are one-shot again, however it ended: bounded, and ended by a signal as any command is.
155
160
  const endPhase = () => {
@@ -161,7 +166,7 @@ const runOnce = async (argv, definition, options) => {
161
166
  // A later phase keeps the --timeout the steps after this one got, not the one this phase already used up.
162
167
  if (explicitEnd !== undefined && oneShot !== undefined)
163
168
  explicitEnd = Date.now() + oneShot;
164
- control.start(afterPhase);
169
+ startDeadline(afterPhase);
165
170
  };
166
171
  const interrupted = (signal) => {
167
172
  // A second signal ends the command as any signal does: one that ignores its phase can still be stopped.
@@ -234,7 +239,7 @@ const runOnce = async (argv, definition, options) => {
234
239
  phased = true;
235
240
  phase = new AbortController();
236
241
  phase.signal.addEventListener("abort", endPhase, { once: true });
237
- control.start(undefined);
242
+ startDeadline(undefined);
238
243
  if (explicitEnd !== undefined)
239
244
  phaseTimer = setTimeout(() => phase.abort(new CliError("timeout", "the command reached its --timeout", {
240
245
  retryable: false,
@@ -243,6 +248,7 @@ const runOnce = async (argv, definition, options) => {
243
248
  return phase.signal;
244
249
  },
245
250
  endUntilStopped: endPhase,
251
+ deadline: () => running,
246
252
  };
247
253
  provide(program, environment);
248
254
  program.hook("preAction", async (_root, action) => {
@@ -330,7 +336,11 @@ const runOnce = async (argv, definition, options) => {
330
336
  ? undefined
331
337
  : lifetime === "interactive"
332
338
  ? HEADLESS_INTERACTIVE_MS
333
- : DEFAULT_COMMAND_MS
339
+ : stopsAtItsPace === true
340
+ ? options.agent
341
+ ? DEFAULT_AGENT_COOPERATIVE_MS
342
+ : DEFAULT_COOPERATIVE_MS
343
+ : DEFAULT_COMMAND_MS
334
344
  : persistent
335
345
  ? undefined
336
346
  : parseDuration(timeout, flags.timeout === undefined ? envName(definition.app, "TIMEOUT") : "--timeout");
@@ -372,7 +382,7 @@ const runOnce = async (argv, definition, options) => {
372
382
  });
373
383
  throw new Relayed(code);
374
384
  }
375
- control.start(deadline);
385
+ startDeadline(deadline);
376
386
  oneShot = deadline;
377
387
  afterPhase = timeout !== undefined || persistent || job || interactiveLogin ? deadline : DEFAULT_COMMAND_MS;
378
388
  if (timeout !== undefined && deadline !== undefined)
@@ -9,11 +9,16 @@ export interface UpgradeContext extends Omit<PackageUpgradePorts, "onInstall"> {
9
9
  }
10
10
  /**
11
11
  * A running serve keeps the code it started with; the update replaced the files under it. Each is restarted by
12
- * the new `script` — `server restart` refuses a serve started by hand, which is then named in `left`.
13
- * `spawn` replaces the child process, as in `UpdateEnvironment`.
12
+ * the new `script` — `server restart` refuses a serve started by hand, which is then named in `left`, and one that
13
+ * ran out of time is named in `timedOut` too. `spawn` replaces the child process, as in `UpdateEnvironment`.
14
14
  */
15
15
  export declare const restartServers: (app: AppIdentity, script: string, env: NodeJS.ProcessEnv, spawn?: (argv: string[]) => number) => {
16
16
  restarted: string[];
17
17
  left: string[];
18
+ timedOut: string[];
19
+ } | {
20
+ timedOut?: undefined;
21
+ restarted: string[];
22
+ left: string[];
18
23
  };
19
24
  export declare const upgradeCommand: (app: AppIdentity, packageName: string, contextFor: (command: Command) => UpgradeContext) => Command;
@@ -2,19 +2,25 @@ import { spawnSync } from "node:child_process";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { annotate } from "@wirecat/cli-base/commands";
4
4
  import { Command } from "commander";
5
+ import { restartMcpServers, staleMcpSessions, thisSystem, } from "../background/after-upgrade.js";
5
6
  import { servingProfiles } from "../background/lock.js";
6
- import { upgradePackage } from "../services/package-upgrade.js";
7
+ import { upgradePackage, } from "../services/package-upgrade.js";
8
+ import { environmentOf } from "./context.js";
7
9
  import { grantsAuthority, LOCAL_WRITE } from "./effects.js";
8
10
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
9
11
  import { usableProfileName } from "./profile.js";
12
+ /** `server restart` waits 30 s for serve to stop and 30 s for it to listen; this is a ceiling over both. */
13
+ const RESTART_CEILING_MS = 90_000;
14
+ const TIMEOUT_EXIT = 9;
10
15
  /**
11
16
  * A running serve keeps the code it started with; the update replaced the files under it. Each is restarted by
12
- * the new `script` — `server restart` refuses a serve started by hand, which is then named in `left`.
13
- * `spawn` replaces the child process, as in `UpdateEnvironment`.
17
+ * the new `script` — `server restart` refuses a serve started by hand, which is then named in `left`, and one that
18
+ * ran out of time is named in `timedOut` too. `spawn` replaces the child process, as in `UpdateEnvironment`.
14
19
  */
15
20
  export const restartServers = (app, script, env, spawn) => {
16
21
  const restarted = [];
17
22
  const left = [];
23
+ const timedOut = [];
18
24
  for (const profile of servingProfiles(app, env)) {
19
25
  try {
20
26
  usableProfileName(profile);
@@ -25,11 +31,40 @@ export const restartServers = (app, script, env, spawn) => {
25
31
  const argv = [script, profile, "server", "restart"];
26
32
  const code = spawn
27
33
  ? spawn([process.execPath, ...argv])
28
- : (spawnSync(process.execPath, argv, { env, shell: false, stdio: ["ignore", 2, 2] }).status ?? 1);
34
+ : (spawnSync(process.execPath, argv, {
35
+ env,
36
+ shell: false,
37
+ stdio: ["ignore", 2, 2],
38
+ timeout: RESTART_CEILING_MS,
39
+ }).status ?? TIMEOUT_EXIT);
29
40
  (code === 0 ? restarted : left).push(profile);
41
+ if (code === TIMEOUT_EXIT)
42
+ timedOut.push(profile);
30
43
  }
31
- return { restarted, left };
44
+ return timedOut.length > 0 ? { restarted, left, timedOut } : { restarted, left };
32
45
  };
46
+ const RESTART_FROM_APP = "restart them from your agent app (start a new chat, or reload its MCP servers)";
47
+ const mcpWarnings = (command, current, servers, stale) => [
48
+ ...servers.flatMap((server) => !server.restarted
49
+ ? [`the HTTP MCP server ${server.name} (PID ${server.pid}) was not restarted: ${server.reason}`]
50
+ : server.to !== null && server.to === server.from
51
+ ? [
52
+ `the HTTP MCP server ${server.name} came back on ${command} ${server.to}: it runs from another install than the one upgraded`,
53
+ ]
54
+ : []),
55
+ ...(stale === undefined
56
+ ? []
57
+ : !stale.checked
58
+ ? [
59
+ `could not check for MCP sessions over stdio (${stale.reason}); any that run keep ${command} ${current} or older — ${RESTART_FROM_APP}`,
60
+ ]
61
+ : stale.sessions.length > 0
62
+ ? [
63
+ `${stale.sessions.length} MCP ${stale.sessions.length === 1 ? "session" : "sessions"} over stdio still ${stale.sessions.length === 1 ? "runs" : "run"} ${command} ${current} or older ` +
64
+ `(PID ${stale.sessions.map((session) => session.pid).join(", ")}) — ${RESTART_FROM_APP}`,
65
+ ]
66
+ : []),
67
+ ];
33
68
  export const upgradeCommand = (app, packageName, contextFor) => grantsAuthority(annotate(new Command("upgrade"), {
34
69
  effects: LOCAL_WRITE,
35
70
  offline: false,
@@ -42,7 +77,19 @@ export const upgradeCommand = (app, packageName, contextFor) => grantsAuthority(
42
77
  throw new CliError("validation_error", `\`${app.command} upgrade\` asks npm for the newest version; not with --offline`);
43
78
  const context = contextFor(this);
44
79
  const { renderer } = context;
45
- const { result, reason, left } = await upgradePackage({ current: app.version, command: app.command, packageName, check: check === true }, { ...context, onInstall: (argv) => renderer.note(`running: ${argv.join(" ")}`) });
80
+ const environment = environmentOf(this);
81
+ const env = environment.env ?? process.env;
82
+ // Built before the install: after it, the old process may no longer find its own lazily loaded files.
83
+ const system = { ...thisSystem(packageName, app.command), ...environment.afterUpgrade };
84
+ const afterUpdate = async () => {
85
+ const servers = (await context.afterUpdate?.()) ?? { restarted: [], left: [] };
86
+ return {
87
+ ...servers,
88
+ mcpServers: await restartMcpServers(app, env, system),
89
+ staleSessions: staleMcpSessions(system),
90
+ };
91
+ };
92
+ const { result, reason, left, timedOut } = await upgradePackage({ current: app.version, command: app.command, packageName, check: check === true }, { ...context, afterUpdate, onInstall: (argv) => renderer.note(`running: ${argv.join(" ")}`) });
46
93
  if (reason === "held_back" && result.heldBack)
47
94
  renderer.warn(heldBackAdvice(result, result.heldBack));
48
95
  if (reason === "unavailable")
@@ -58,9 +105,14 @@ export const upgradeCommand = (app, packageName, contextFor) => grantsAuthority(
58
105
  renderer.warn(advice);
59
106
  }
60
107
  for (const profile of left) {
61
- renderer.warn(`the server for profile ${profile} still runs ${app.command} ${app.version} — ` +
62
- `\`${app.command} ${profile} server restart\``);
108
+ renderer.warn(timedOut?.includes(profile)
109
+ ? `the server for profile ${profile} did not restart in time and may still run ${app.command} ${app.version} — ` +
110
+ `\`${app.command} ${profile} server status\``
111
+ : `the server for profile ${profile} still runs ${app.command} ${app.version} — ` +
112
+ `\`${app.command} ${profile} server restart\``);
63
113
  }
114
+ for (const line of mcpWarnings(app.command, app.version, result.mcpServers ?? [], result.staleSessions))
115
+ renderer.warn(line);
64
116
  renderAnswer(this, context, itemOf(snakeKeys(result)), () => summary(app.command, result));
65
117
  });
66
118
  const heldBackAdvice = ({ installed, latest, installer }, { until, command }) => {
@@ -75,7 +127,11 @@ const summary = (command, result) => {
75
127
  const servers = result.restarted.length > 0
76
128
  ? `; restarted the server for ${result.restarted.map((profile) => `profile ${profile}`).join(", ")}`
77
129
  : "";
78
- return `${command} ${result.current} → ${result.installed ?? result.latest}${held}${servers}`;
130
+ const mcp = (result.mcpServers ?? []).filter((server) => server.restarted);
131
+ const http = mcp.length > 0
132
+ ? `; restarted the HTTP MCP server ${mcp.map((server) => `${server.name} (${server.from ?? "?"} → ${server.to ?? "?"})`).join(", ")}`
133
+ : "";
134
+ return `${command} ${result.current} → ${result.installed ?? result.latest}${held}${servers}${http}`;
79
135
  }
80
136
  if (held)
81
137
  return `${command} ${result.current}${held}`;
@@ -7,6 +7,8 @@ export declare const time: v.SchemaWithPipe<readonly [v.StringSchema<undefined>,
7
7
  /** A time a caller sends: any offset, read as the moment it names. Answers carry `time`, always UTC. */
8
8
  export declare const zonedTime: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time with a zone">]>;
9
9
  export declare const count: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
10
+ /** Fields of an answer item someone other than the owner wrote, as dotted paths: an agent reads them as data. */
11
+ export declare const untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
10
12
  export declare const providerMetadata: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.UnknownSchema, undefined>, undefined>;
11
13
  export declare const author: v.StrictObjectSchema<{
12
14
  readonly type: v.PicklistSchema<["person", "bot"], undefined>;
@@ -7,6 +7,8 @@ export const time = isoTime;
7
7
  /** A time a caller sends: any offset, read as the moment it names. Answers carry `time`, always UTC. */
8
8
  export const zonedTime = v.pipe(v.string(), v.isoTimestamp("an ISO 8601 time with a zone"));
9
9
  export const count = v.pipe(v.number(), v.integer(), v.minValue(0));
10
+ /** Fields of an answer item someone other than the owner wrote, as dotted paths: an agent reads them as data. */
11
+ export const untrusted = v.optional(v.array(text));
10
12
  export const providerMetadata = v.optional(v.record(v.string(), v.unknown()));
11
13
  export const author = v.strictObject({
12
14
  type: v.picklist(["person", "bot"]),
@@ -22,6 +22,6 @@ export declare const hit: v.StrictObjectSchema<{
22
22
  readonly scope: v.OptionalSchema<v.PicklistSchema<["transcript", "chat", "summary"], undefined>, undefined>;
23
23
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
24
24
  readonly id: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
25
- /** An event hit's fields that whoever sent the invitation wrote: an agent reads them as data. */
25
+ /** Absent on a note hit: the owner wrote it. */
26
26
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
27
27
  }, undefined>;
@@ -1,5 +1,5 @@
1
1
  import * as v from "valibot";
2
- import { count, reference, text, time } from "./fields.js";
2
+ import { count, reference, text, time, untrusted } from "./fields.js";
3
3
  /** One result of `search all`: a message, a mail, a note, a calendar event or a meeting passage, best first. */
4
4
  export const hit = v.strictObject({
5
5
  kind: v.picklist(["message", "mail", "note", "document", "event", "meeting"]),
@@ -23,6 +23,6 @@ export const hit = v.strictObject({
23
23
  scope: v.optional(v.picklist(["transcript", "chat", "summary"])),
24
24
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
25
25
  id: v.optional(v.pipe(v.string(), v.minLength(1))),
26
- /** An event hit's fields that whoever sent the invitation wrote: an agent reads them as data. */
27
- untrusted: v.optional(v.array(text)),
26
+ /** Absent on a note hit: the owner wrote it. */
27
+ untrusted,
28
28
  });