@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
@@ -17,7 +17,8 @@ export interface CalendarWriterFixture {
17
17
  }
18
18
  /**
19
19
  * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
20
- * retried with its id finds the event; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
20
+ * retried with its id finds the event; a repeat rule is kept as given and changed on the series; a timed event
21
+ * switches to all-day and back; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
21
22
  * default never reaches the provider with a write that would.
22
23
  */
23
24
  export declare const calendarWriterContract: (fixture: () => CalendarWriterFixture | Promise<CalendarWriterFixture>) => ContractCase[];
@@ -17,9 +17,12 @@ const rejectsWith = async (promise, check, message) => {
17
17
  assert.fail(`${message}, but it succeeded`);
18
18
  };
19
19
  const ID = "contract0sample0id0001";
20
+ const WEEKLY = "RRULE:FREQ=WEEKLY;COUNT=3";
21
+ const DAILY = "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5";
20
22
  /**
21
23
  * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
22
- * retried with its id finds the event; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
24
+ * retried with its id finds the event; a repeat rule is kept as given and changed on the series; a timed event
25
+ * switches to all-day and back; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
23
26
  * default never reaches the provider with a write that would.
24
27
  */
25
28
  export const calendarWriterContract = (fixture) => {
@@ -75,6 +78,47 @@ export const calendarWriterContract = (fixture) => {
75
78
  : f.writer.respond(f.calendar, id, "declined", options), (error) => reasonOf(error) === "event.conflict", "a stale etag is a calendarConflict()");
76
79
  },
77
80
  })),
81
+ {
82
+ name: "a create with a repeat rule returns the series holding that rule",
83
+ run: async () => {
84
+ const f = await fixture();
85
+ const made = await f.writer.create(f.calendar, draft(f.at, { recurrence: [WEEKLY] }), {
86
+ id: ID,
87
+ notify: false,
88
+ });
89
+ assert.equal(made.kind, "series");
90
+ assert.deepEqual(made.recurrence, [WEEKLY]);
91
+ },
92
+ },
93
+ {
94
+ name: "a change of the series' rule replaces it",
95
+ run: async () => {
96
+ const f = await fixture();
97
+ const made = await f.writer.create(f.calendar, draft(f.at, { recurrence: [WEEKLY] }), {
98
+ id: ID,
99
+ notify: false,
100
+ });
101
+ const changed = await f.writer.update(f.calendar, made.externalId, { recurrence: [DAILY] }, { scope: "series", etag: made.etag, notify: false });
102
+ assert.deepEqual(changed.recurrence, [DAILY]);
103
+ assert.notEqual(changed.etag, made.etag);
104
+ },
105
+ },
106
+ {
107
+ name: "a timed event becomes all-day, and back",
108
+ run: async () => {
109
+ const f = await fixture();
110
+ const made = await f.writer.create(f.calendar, draft(f.at), { id: ID, notify: false });
111
+ const day = new Date(f.at).toISOString().slice(0, 10);
112
+ const next = new Date(f.at + 86_400_000).toISOString().slice(0, 10);
113
+ const allDay = await f.writer.update(f.calendar, made.externalId, { startDate: day, endDate: next, timezone: "UTC" }, { scope: "this", etag: made.etag, notify: false });
114
+ assert.equal(allDay.allDay, true);
115
+ assert.equal(allDay.startDate, day);
116
+ const timed = await f.writer.update(f.calendar, made.externalId, { startsAt: f.at, endsAt: f.at + 3_600_000, timezone: "UTC" }, { scope: "this", etag: allDay.etag, notify: false });
117
+ assert.equal(timed.allDay ?? false, false);
118
+ assert.equal(timed.startsAt, f.at);
119
+ assert.equal(timed.startDate ?? null, null);
120
+ },
121
+ },
78
122
  {
79
123
  name: "notify reaches the provider as given: false mails nobody",
80
124
  run: async () => {
@@ -122,7 +166,7 @@ export const calendarWriterContract = (fixture) => {
122
166
  ask: async () => { },
123
167
  now: f.at,
124
168
  zone: "UTC",
125
- names: { notify: "notify", id: "id", calendar: "calendar", series: "series" },
169
+ names: { notify: "notify", id: "id", calendar: "calendar", series: "series", repeat: "repeat" },
126
170
  }, String(calendar?.id), {
127
171
  title: "Example planning",
128
172
  startTime: new Date(f.at).toISOString(),
@@ -65,7 +65,7 @@ export const fakeCalendarWriter = (options = {}) => {
65
65
  return answer(existing);
66
66
  const entry = stamp({
67
67
  externalId: id,
68
- kind: "single",
68
+ kind: draft.recurrence ? "series" : "single",
69
69
  status: "confirmed",
70
70
  participants: [],
71
71
  ...fields(draft),
@@ -3,7 +3,8 @@ import type { EntryInput, ParticipantResponse, StoredCalendar } from "../meeting
3
3
  import type { Level, PermissionKey } from "../sends/permissions.js";
4
4
  /**
5
5
  * A new event in neutral words: a timed one has `startsAt` and `endsAt` (epoch ms, end exclusive) in `timezone`; an
6
- * all-day one has `startDate` and `endDate` (`YYYY-MM-DD`, end exclusive). Never repeating.
6
+ * all-day one has `startDate` and `endDate` (`YYYY-MM-DD`, end exclusive). A repeating one has `recurrence`, RFC 5545
7
+ * lines as `EntryInput` holds them; its `timezone` is the zone the rule is expanded in, which Google requires.
7
8
  */
8
9
  export interface EventDraft {
9
10
  title: string;
@@ -18,8 +19,12 @@ export interface EventDraft {
18
19
  email: string;
19
20
  optional?: boolean;
20
21
  }[];
22
+ recurrence?: string[];
21
23
  }
22
- /** Only the fields to change; `null` clears one. `attendees` replaces the whole list. */
24
+ /**
25
+ * Only the fields to change; `null` clears one. `attendees` and `recurrence` replace the whole list. A patch with
26
+ * `startDate` for a timed event, or `startsAt` for an all-day one, switches it: the adapter clears the other kind.
27
+ */
23
28
  export type EventPatch = Partial<EventDraft>;
24
29
  /** `this`: the one entry named; `series`: the repeating event it belongs to, named by the series' own id. */
25
30
  export type WriteScope = "this" | "series";
@@ -29,6 +29,8 @@ export interface EventFields {
29
29
  location?: string;
30
30
  description?: string;
31
31
  attendees?: string[];
32
+ /** An RFC 5545 rule, with or without `RRULE:`; on a change, only with the series' scope. */
33
+ repeat?: string;
32
34
  }
33
35
  export interface WriteContext {
34
36
  store: EventStore;
@@ -44,11 +46,16 @@ export interface WriteContext {
44
46
  id: string;
45
47
  calendar: string;
46
48
  series: string;
49
+ repeat: string;
47
50
  };
48
51
  }
49
- /** Typed fields into a draft, or into a patch with only what was given. */
50
- export declare const draftOf: (fields: EventFields, zone: string, { whole }: {
52
+ /**
53
+ * Typed fields into a draft, or into a patch with only what was given. `allDay` is the kind of the event a patch
54
+ * changes, for a repeat rule's UNTIL when the patch keeps the times.
55
+ */
56
+ export declare const draftOf: (fields: EventFields, zone: string, { whole, allDay }: {
51
57
  whole: boolean;
58
+ allDay?: boolean;
52
59
  }) => EventPatch;
53
60
  /** An id every provider takes as a client-chosen one: Google's base32hex `[a-v0-9]`, a CalDAV UID, a Graph id. */
54
61
  export declare const newEventId: () => string;
@@ -73,6 +80,7 @@ export declare const createEvent: (context: WriteContext, calendarReference: str
73
80
  url: string | null;
74
81
  deleted_at: string | null;
75
82
  } | null;
83
+ hint?: string | undefined;
76
84
  }>;
77
85
  export declare const updateEvent: (context: WriteContext, reference: string, fields: EventFields, options?: {
78
86
  calendar?: string;
@@ -96,6 +104,7 @@ export declare const updateEvent: (context: WriteContext, reference: string, fie
96
104
  url: string | null;
97
105
  deleted_at: string | null;
98
106
  } | null;
107
+ hint?: string | undefined;
99
108
  }>;
100
109
  export declare const deleteEvent: (context: WriteContext, reference: string, options?: {
101
110
  calendar?: string;
@@ -128,4 +137,5 @@ export declare const respondToEvent: (context: WriteContext, reference: string,
128
137
  url: string | null;
129
138
  deleted_at: string | null;
130
139
  } | null;
140
+ hint?: string | undefined;
131
141
  }>;
@@ -6,6 +6,7 @@ import { guardedWrite } from "../sends/guarded.js";
6
6
  import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
7
7
  import { newOperationId } from "../sends/send-id.js";
8
8
  import { calendarIn, entryItem, eventIdOf, eventOf, plusDays } from "./reads.js";
9
+ import { kindBoundLines, repeatRule } from "./recurrence.js";
9
10
  import { CALENDAR_WRITE_DEFAULTS, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
10
11
  export const EVENT_RESPONSES = ["accepted", "tentative", "declined"];
11
12
  /** Each write is checked by its command's path, the key a declared CLI's `commandPermissions` already knows. */
@@ -24,8 +25,11 @@ const dateOf = (text, what) => {
24
25
  return text;
25
26
  };
26
27
  const momentOf = (text, zone) => zonedMoment(text, zone, { start: 0, end: text.length });
27
- /** Typed fields into a draft, or into a patch with only what was given. */
28
- export const draftOf = (fields, zone, { whole }) => {
28
+ /**
29
+ * Typed fields into a draft, or into a patch with only what was given. `allDay` is the kind of the event a patch
30
+ * changes, for a repeat rule's UNTIL when the patch keeps the times.
31
+ */
32
+ export const draftOf = (fields, zone, { whole, allDay = false }) => {
29
33
  const patch = {};
30
34
  if (fields.title !== undefined) {
31
35
  if (!fields.title.trim())
@@ -66,6 +70,12 @@ export const draftOf = (fields, zone, { whole }) => {
66
70
  throw new CliError("validation_error", `${email} is not an email address`);
67
71
  return { email };
68
72
  });
73
+ if (fields.repeat !== undefined)
74
+ patch.recurrence = [
75
+ repeatRule(fields.repeat, {
76
+ allDay: patch.startDate !== undefined || (patch.startsAt === undefined && allDay),
77
+ }),
78
+ ];
69
79
  if (!whole && Object.keys(patch).length === 0)
70
80
  throw new CliError("validation_error", "nothing to change");
71
81
  return patch;
@@ -130,10 +140,12 @@ const keysFor = (context, write, notify) => [
130
140
  ...(context.writes.extraKeys?.(write) ?? []),
131
141
  ...(notify ? [EVENTS_NOTIFY_KEY] : []),
132
142
  ];
133
- const answerOf = (operationId, saved) => ({
143
+ const answerOf = (operationId, saved, repeats = false) => ({
134
144
  operation_id: operationId,
135
145
  event: saved?.eventId == null ? null : `event:${saved.eventId}`,
136
146
  entry: saved ? entryItem(saved) : null,
147
+ // Only the series is saved here; its occurrences come with the provider's expansion on the next sync.
148
+ ...(repeats ? { hint: "sync to store its occurrences" } : {}),
137
149
  });
138
150
  /** An id every provider takes as a client-chosen one: Google's base32hex `[a-v0-9]`, a CalDAV UID, a Graph id. */
139
151
  export const newEventId = () => randomUUID().replaceAll("-", "");
@@ -159,7 +171,7 @@ export const createEvent = async (context, calendarReference, fields, options =
159
171
  throw error;
160
172
  }
161
173
  const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
162
- return answerOf(id, saved);
174
+ return answerOf(id, saved, draft.recurrence !== undefined);
163
175
  };
164
176
  const targetOf = async (context, reference, { calendar, scope }) => {
165
177
  const details = await eventOf(context.store, context.accountId, eventIdOf(reference));
@@ -196,14 +208,28 @@ const targetOf = async (context, reference, { calendar, scope }) => {
196
208
  };
197
209
  };
198
210
  export const updateEvent = async (context, reference, fields, options = {}) => {
199
- const patch = draftOf(fields, context.zone, { whole: false });
200
211
  const scope = options.series ? "series" : "this";
212
+ if (fields.repeat !== undefined && scope !== "series")
213
+ throw new CliError("validation_error", `a repeat rule belongs to the whole series: add ${context.names.series}`, {
214
+ param: context.names.series,
215
+ });
201
216
  const { calendar, target, participants } = await targetOf(context, reference, { ...options, scope });
217
+ const patch = draftOf(fields, context.zone, { whole: false, allDay: target.allDay });
218
+ const kept = (target.recurrence ?? []).filter((line) => !/^RRULE:/iu.test(line));
219
+ if (patch.recurrence)
220
+ patch.recurrence = [...patch.recurrence, ...kept];
221
+ const switching = patch.startDate !== undefined ? !target.allDay : patch.startsAt !== undefined && target.allDay;
222
+ const bound = kindBoundLines(patch.recurrence ? kept : (target.recurrence ?? []));
223
+ if (switching && bound.length)
224
+ throw new CliError("validation_error", `${reference} repeats with ${bound.join(", ")}, which hold a ${target.allDay ? "date" : "time"}; ` +
225
+ (patch.recurrence || bound.some((line) => !/^RRULE:/iu.test(line))
226
+ ? "change its kind in the calendar itself"
227
+ : `give its rule again with ${context.names.repeat} and UNTIL as a ${target.allDay ? "UTC time" : "date"}`), { reason: "event.repeat-kind" });
202
228
  const notify = notifyOf(context, options.notify, hasOthers(participants) || (patch.attendees?.length ?? 0) > 0);
203
229
  const operationId = newOperationId();
204
230
  const entry = await guardedWrite(writeGuard(context, keysFor(context, "update", notify)), { chatId: null, kind: "edit", key: COMMAND_KEY.update, operationId, notify }, () => context.writes.writer.update(calendar, target.externalId, patch, { scope, etag: target.etag, notify }));
205
231
  const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
206
- return answerOf(operationId, saved);
232
+ return answerOf(operationId, saved, patch.recurrence !== undefined || (switching && scope === "series"));
207
233
  };
208
234
  export const deleteEvent = async (context, reference, options = {}) => {
209
235
  const scope = options.series ? "series" : "this";
@@ -12,8 +12,16 @@ import { botExamples } from "./examples.js";
12
12
  /** `bot mcp`, mounted only for a messenger that hands in its `run` on `BotMessenger.mcp`. */
13
13
  export const botMcpCommand = (bot) => {
14
14
  const { app } = bot;
15
- const command = annotate(new Command("mcp"), { effects: REMOTE_WRITE, lifetime: "persistent", mcp: false })
16
- .description(`serve this bot to an agent over MCP, on stdin and stdout — \`claude mcp add sales-bot -- ${app.command} sales bot mcp\``)
15
+ const command = annotate(new Command("mcp"), { effects: LOCAL_READ, mcp: false })
16
+ .description(`serve this bot to an agent over MCP and set it up — \`claude mcp add sales-bot -- ${app.command} sales bot mcp serve\``)
17
+ .summary("serve this bot to an agent over MCP, and set that up");
18
+ command.addCommand(annotate(new Command("serve"), {
19
+ effects: REMOTE_WRITE,
20
+ lifetime: "persistent",
21
+ mcp: false,
22
+ examples: botExamples(bot, "mcp serve"),
23
+ })
24
+ .description(`serve this bot to an agent over MCP, on stdin and stdout — \`claude mcp add sales-bot -- ${app.command} sales bot mcp serve\``)
17
25
  .summary("serve this bot to an agent over MCP, on stdin and stdout")
18
26
  .action(async function () {
19
27
  const context = botContext(this, bot);
@@ -35,7 +43,7 @@ export const botMcpCommand = (bot) => {
35
43
  ...(mcp.keyOf ? { keyOf: mcp.keyOf } : {}),
36
44
  ...(mcp.skill ? { skill: mcp.skill } : {}),
37
45
  }, context.streams.diagnostic);
38
- });
46
+ }));
39
47
  command.addCommand(annotate(new Command("config"), { effects: LOCAL_READ, examples: botExamples(bot, "mcp config") })
40
48
  .description("print the mcpServers entry for Claude Desktop, Cursor and others, with full paths; writes nothing")
41
49
  .summary("print the mcpServers entry for an MCP client; writes nothing")
@@ -1,6 +1,7 @@
1
1
  import { type KeyringStore, type Renderer, type RenderFormat, type Streams } from "@wirecat/cli-base";
2
2
  import type { ErrorObject } from "@wirecat/cli-base/standards";
3
3
  import type { Command } from "commander";
4
+ import { type Deadline, type RunBudget } from "../background/budget.js";
4
5
  import type { MessageEvent } from "../domain/models.js";
5
6
  import type { AppIdentity } from "../ports/app.js";
6
7
  import type { MessengerAdapter } from "../ports/messenger.js";
@@ -71,6 +72,8 @@ export interface BaseEnvironment {
71
72
  * what each step brings and may stop at any point: the history a phone pushes after linking.
72
73
  */
73
74
  untilStopped?: () => AbortSignal;
75
+ /** Set by `run()`: the deadline running now, or none (a persistent command, a phase, a background job). */
76
+ deadline?: () => Deadline | undefined;
74
77
  /**
75
78
  * Set by `run()`: ends the phase `untilStopped()` began without aborting its signal — the command saw it finish.
76
79
  * However a phase ends, the steps after it get the command's one-shot deadline again, started afresh, and a signal
@@ -101,6 +104,11 @@ export interface RelayedRun {
101
104
  /** A promise when the lender opens it on first use. */
102
105
  connection: () => MessengerAdapter | Promise<MessengerAdapter>;
103
106
  }
107
+ /**
108
+ * A budget for a resumable sync, from the deadline running now and the CLI's `requestMs`: so a lower `--timeout`
109
+ * means fewer requests. `requests` (`--max-pages`) can only lower it.
110
+ */
111
+ export declare const runBudgetOf: (command: Command, requests?: number) => RunBudget;
104
112
  /** A partial answer's exit code, for this run alone. */
105
113
  export declare const setExitCode: (command: Command, code: number) => void;
106
114
  export declare const provide: (program: Command, environment: BaseEnvironment) => void;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { runBudget } from "../background/budget.js";
2
3
  import { resolveOutput } from "../output.js";
3
4
  import { recorded } from "../runs/recording.js";
4
5
  import { runContextOf } from "../runs/run-context.js";
@@ -6,6 +7,20 @@ import { withDeadline } from "./deadline.js";
6
7
  import { withAbort } from "./execution.js";
7
8
  import { rootOf } from "./profile.js";
8
9
  import { namedProfile } from "./settings.js";
10
+ /**
11
+ * A budget for a resumable sync, from the deadline running now and the CLI's `requestMs`: so a lower `--timeout`
12
+ * means fewer requests. `requests` (`--max-pages`) can only lower it.
13
+ */
14
+ export const runBudgetOf = (command, requests) => {
15
+ const { deadline, signal, app } = environmentOf(command);
16
+ const now = deadline?.();
17
+ return runBudget({
18
+ ...(requests === undefined ? {} : { requests }),
19
+ ...(app?.requestMs === undefined ? {} : { requestMs: app.requestMs }),
20
+ ...(now ? { deadline: now } : {}),
21
+ ...(signal ? { signal } : {}),
22
+ });
23
+ };
9
24
  /** A partial answer's exit code, for this run alone. */
10
25
  export const setExitCode = (command, code) => {
11
26
  const slot = environmentOf(command).exit;
@@ -1,6 +1,13 @@
1
1
  import { type Streams } from "@wirecat/cli-base";
2
2
  import type { Closeable } from "./deadline.js";
3
3
  export declare const DEFAULT_COMMAND_MS = 30000;
4
+ /** A `cooperative` command stops on its own before its deadline and keeps its work, so it may run longer. */
5
+ export declare const DEFAULT_COOPERATIVE_MS: number;
6
+ /**
7
+ * The same over MCP: clients cut a tool call at about 60 s (the MCP TypeScript SDK's `DEFAULT_REQUEST_TIMEOUT_MSEC`
8
+ * is 60000), so the command answers well before that.
9
+ */
10
+ export declare const DEFAULT_AGENT_COOPERATIVE_MS = 45000;
4
11
  export declare const DEFAULT_OUTPUT_BYTES: number;
5
12
  export declare const byteCount: (value: string, option: string, zero?: boolean) => number;
6
13
  export interface Execution {
@@ -2,6 +2,13 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { stopWrites, withWriteScope } from "../sends/guarded.js";
3
3
  import { fieldsOf, projectFields } from "./result-fields.js";
4
4
  export const DEFAULT_COMMAND_MS = 30_000;
5
+ /** A `cooperative` command stops on its own before its deadline and keeps its work, so it may run longer. */
6
+ export const DEFAULT_COOPERATIVE_MS = 5 * 60_000;
7
+ /**
8
+ * The same over MCP: clients cut a tool call at about 60 s (the MCP TypeScript SDK's `DEFAULT_REQUEST_TIMEOUT_MSEC`
9
+ * is 60000), so the command answers well before that.
10
+ */
11
+ export const DEFAULT_AGENT_COOPERATIVE_MS = 45_000;
5
12
  export const DEFAULT_OUTPUT_BYTES = 4 * 1024 * 1024;
6
13
  export const byteCount = (value, option, zero = false) => {
7
14
  const result = Number(value);
package/dist/cli/guide.js CHANGED
@@ -291,7 +291,7 @@ export const checkStep = (program, run) => {
291
291
  const beforeDashes = rest.slice(index, rest.includes("--") ? rest.indexOf("--") : undefined);
292
292
  if (help && beforeDashes.some((word) => word === help.long || word === help.short))
293
293
  return [];
294
- // A group with an action of its own runs as typed: `mcp` serves, `mcp server start` is its child.
294
+ // A group with an action of its own runs as typed rather than asking for one of its children.
295
295
  const runsItself = at._actionHandler != null;
296
296
  if (at.commands.length > 0 && !runsItself)
297
297
  return [`needs one of: ${at.commands.map((one) => one.name()).join(", ")}`];
@@ -57,7 +57,7 @@ export { CONTRACT } from "./commands-command.js";
57
57
  export { refuseUnknownKey } from "./config-command.js";
58
58
  export { type ConfigIssue, type ConfigIssueKind, checkConfigText, issueMessage, readConfigFile, readConfigText, settingPathOf, withoutSetting, } from "./config-issues.js";
59
59
  export { loadSnakeCaseConfig } from "./config-keys.js";
60
- export { type AgentRun, type BaseContext, type BaseEnvironment, baseContext, environmentOf, outputFor, provide, secretInputOf, } from "./context.js";
60
+ export { type AgentRun, type BaseContext, type BaseEnvironment, baseContext, environmentOf, outputFor, provide, runBudgetOf, secretInputOf, } from "./context.js";
61
61
  export { addContractInfo, type ContractEvidenceOptions, contractEvidence } from "./contract-evidence.js";
62
62
  export { sharedCommandTemplate } from "./contract-templates.js";
63
63
  export { type Closeable, withDeadline } from "./deadline.js";
package/dist/cli/index.js CHANGED
@@ -50,7 +50,7 @@ export { CONTRACT } from "./commands-command.js";
50
50
  export { refuseUnknownKey } from "./config-command.js";
51
51
  export { checkConfigText, issueMessage, readConfigFile, readConfigText, settingPathOf, withoutSetting, } from "./config-issues.js";
52
52
  export { loadSnakeCaseConfig } from "./config-keys.js";
53
- export { baseContext, environmentOf, outputFor, provide, secretInputOf, } from "./context.js";
53
+ export { baseContext, environmentOf, outputFor, provide, runBudgetOf, secretInputOf, } from "./context.js";
54
54
  export { addContractInfo, contractEvidence } from "./contract-evidence.js";
55
55
  export { sharedCommandTemplate } from "./contract-templates.js";
56
56
  export { withDeadline } from "./deadline.js";
@@ -10,7 +10,7 @@ export interface McpServerTarget {
10
10
  envPrefix: string;
11
11
  env: NodeJS.ProcessEnv;
12
12
  tokenFile: string;
13
- /** The words after the CLI's own entry that run `mcp --http` in the foreground, before `--port`. */
13
+ /** The words after the CLI's own entry that run `mcp serve --http` in the foreground, before `--port`. */
14
14
  argv: string[];
15
15
  /** Added to the server's environment: the profile, for a CLI that reads it from there. */
16
16
  childEnv?: Record<string, string>;
@@ -19,17 +19,22 @@ export interface McpServerTarget {
19
19
  export interface McpServerOptions {
20
20
  /** How help shows the CLI before `mcp`: `tg personal`. */
21
21
  example: string;
22
- /** The CLI's own `mcp --http` default, when it is not 8765. */
22
+ /** The CLI's own `mcp serve --http` default, when it is not 8765. */
23
23
  defaultPort?: number;
24
+ /**
25
+ * `start` takes `--permission` and hands it to the server: only for a CLI whose `mcp serve` takes it too,
26
+ * since a server that refuses the option dies before it listens.
27
+ */
28
+ permission?: boolean;
24
29
  target: (self: Command) => McpServerTarget;
25
30
  /** The CLI's own answer: the item in `--json`, else the lines. */
26
31
  answer: (self: Command, item: object, lines: string[]) => void;
27
32
  system?: (self: Command) => ServerSystem | undefined;
28
33
  }
29
34
  /**
30
- * `mcp --http` in the background: `start`, `stop`, `status`, as `server` does for `serve`. The server
35
+ * `mcp serve --http` in the background: `start`, `stop`, `status`, as `server` does for `serve`. The server
31
36
  * keeps a lock beside its token file (`state.ts`) with its port, address and login code, so status
32
37
  * survives the shell that started it, and `stop` ends only a process that carries this CLI's marker.
33
38
  * None of it is offered over MCP: status holds a live login code.
34
39
  */
35
- export declare const mcpServerCommand: ({ example, defaultPort, target, answer, system, }: McpServerOptions) => Command;
40
+ export declare const mcpServerCommand: ({ example, defaultPort, permission, target, answer, system, }: McpServerOptions) => Command;
@@ -1,11 +1,11 @@
1
- import { CliError } from "@wirecat/cli-base";
1
+ import { CliError, writeSecurely } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command } from "commander";
4
4
  import { alive, carries } from "../background/processes.js";
5
5
  import { thisMachine } from "../background/system.js";
6
6
  import { readableLog, tail } from "../background/units.js";
7
7
  import { mcpUrlOf, portOf, publicUrlOf } from "../http/public-url.js";
8
- import { httpLockFile, httpLogFile, mcpHttpMarker, readHttpLock, signedInClients, } from "../mcp/http/state.js";
8
+ import { httpLaunchFile, httpLockFile, httpLogFile, mcpHttpMarker, readHttpLock, signedInClients, } from "../mcp/http/state.js";
9
9
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
10
10
  export const MCP_HTTP_DEFAULT_PORT = 8765;
11
11
  const LOOKS = 120;
@@ -40,28 +40,29 @@ const clientLines = (clients) => clients.length === 0
40
40
  .join(", ")}.`,
41
41
  ];
42
42
  /**
43
- * `mcp --http` in the background: `start`, `stop`, `status`, as `server` does for `serve`. The server
43
+ * `mcp serve --http` in the background: `start`, `stop`, `status`, as `server` does for `serve`. The server
44
44
  * keeps a lock beside its token file (`state.ts`) with its port, address and login code, so status
45
45
  * survives the shell that started it, and `stop` ends only a process that carries this CLI's marker.
46
46
  * None of it is offered over MCP: status holds a live login code.
47
47
  */
48
- export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT, target, answer, system, }) => {
48
+ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT, permission = false, target, answer, system, }) => {
49
49
  const command = annotate(new Command("server"), { effects: LOCAL_READ, mcp: false })
50
50
  .summary("run the HTTP MCP server in the background")
51
- .description("start, stop and check `mcp --http` in the background; status shows the login code for a browser app");
51
+ .description("start, stop and check `mcp serve --http` in the background; status shows the login code for a browser app");
52
52
  const machine = (self) => system?.(self) ?? thisMachine();
53
- destroysNothing(annotate(command.command("start"), {
53
+ const start = destroysNothing(annotate(command.command("start"), {
54
54
  effects: LOCAL_WRITE,
55
55
  mcp: false,
56
56
  examples: [`${example} mcp server start --public-url https://laptop.example.ts.net`],
57
57
  }))
58
58
  .summary("start the HTTP MCP server in the background")
59
- .description("start `mcp --http` apart from this shell and answer once it listens, with its login code")
59
+ .description("start `mcp serve --http` apart from this shell and answer once it listens, with its login code")
60
60
  .option("--port <port>", `the local port (default ${defaultPort})`)
61
- .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")
62
- .option("--permission <key=level>", "make a permission stricter for this server only; repeat for more keys", (value, previous = []) => [...previous, value])
63
- .action(async function () {
64
- // A CLI whose `mcp` takes the same flags reads them first, so they are looked up on both.
61
+ .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");
62
+ if (permission)
63
+ start.option("--permission <key=level>", "make a permission stricter for this server only; repeat for more keys", (value, previous = []) => [...previous, value]);
64
+ start.action(async function () {
65
+ // A CLI with a root-wide `--permission` hands it over too, so the server is never looser than asked.
65
66
  const flags = this.optsWithGlobals();
66
67
  const one = target(this);
67
68
  const url = publicUrlOf(flags.publicUrl, {
@@ -81,14 +82,20 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
81
82
  const sys = machine(this);
82
83
  const log = httpLogFile(one.tokenFile);
83
84
  const { [`${one.envPrefix}_TIMEOUT`]: _timeout, ...inherited } = one.env;
84
- const pid = sys.spawn([
85
- ...one.argv,
86
- "--port",
87
- String(port),
88
- "--public-url",
89
- url.pathname === "/" ? url.origin : url.href,
90
- ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
91
- ], { ...inherited, ...one.childEnv, [mcpHttpMarker(one.envPrefix)]: one.profile ?? "default" }, log);
85
+ const launch = {
86
+ invocation: one.invocation,
87
+ argv: [
88
+ ...one.argv,
89
+ "--port",
90
+ String(port),
91
+ "--public-url",
92
+ url.pathname === "/" ? url.origin : url.href,
93
+ ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
94
+ ],
95
+ env: { ...one.childEnv, [mcpHttpMarker(one.envPrefix)]: one.profile ?? "default" },
96
+ };
97
+ writeSecurely(httpLaunchFile(lockFile), `${JSON.stringify(launch)}\n`, 0o600);
98
+ const pid = sys.spawn(launch.argv, { ...inherited, ...launch.env }, log);
92
99
  for (let look = 0; look < LOOKS; look++) {
93
100
  const now = readHttpLock(lockFile);
94
101
  if (now?.pid === pid && now.listeningAt) {
@@ -102,15 +109,15 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
102
109
  }
103
110
  if (!alive(pid)) {
104
111
  const last = readableLog(tail(log, 5)).trim();
105
- throw new CliError("provider_unavailable", `mcp --http stopped before it listened${last ? `:\n${last}` : ""}`);
112
+ throw new CliError("provider_unavailable", `mcp serve --http stopped before it listened${last ? `:\n${last}` : ""}`, { retryable: false });
106
113
  }
107
114
  await sys.pause();
108
115
  }
109
- throw new CliError("timeout", `mcp --http is running but not listening yet — \`${one.invocation} mcp server status\`; its log: ${log}`);
116
+ throw new CliError("timeout", `mcp serve --http is running but not listening yet — \`${one.invocation} mcp server status\`; its log: ${log}`);
110
117
  });
111
118
  destroysNothing(annotate(command.command("stop"), { effects: LOCAL_WRITE, mcp: false, examples: [`${example} mcp server stop`] }))
112
119
  .summary("stop the background HTTP MCP server")
113
- .description("stop `mcp --http` — only one that `mcp server start` started")
120
+ .description("stop `mcp serve --http` — only one that `mcp server start` started")
114
121
  .action(async function () {
115
122
  const one = target(this);
116
123
  const lock = readHttpLock(httpLockFile(one.tokenFile));
@@ -121,14 +128,14 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
121
128
  return;
122
129
  }
123
130
  if (byOf(one, lock.pid) !== "server") {
124
- throw new CliError("validation_error", `mcp --http for ${one.invocation} (PID ${lock.pid}) was not started by \`${one.invocation} mcp server start\` — stop it where it runs`);
131
+ throw new CliError("validation_error", `mcp serve --http for ${one.invocation} (PID ${lock.pid}) was not started by \`${one.invocation} mcp server start\` — stop it where it runs`);
125
132
  }
126
133
  process.kill(lock.pid, "SIGTERM");
127
134
  const sys = machine(this);
128
135
  for (let look = 0; look < LOOKS && alive(lock.pid); look++)
129
136
  await sys.pause();
130
137
  if (alive(lock.pid))
131
- throw new CliError("timeout", `mcp --http (PID ${lock.pid}) has not stopped yet`);
138
+ throw new CliError("timeout", `mcp serve --http (PID ${lock.pid}) has not stopped yet`);
132
139
  answer(this, { profile: one.profile ?? null, stopped: true, pid: lock.pid }, [
133
140
  `Stopped serving ${one.invocation} over HTTP (PID ${lock.pid}).`,
134
141
  ]);
@@ -139,7 +146,7 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
139
146
  examples: [`${example} mcp server status --json`],
140
147
  })
141
148
  .summary("whether the HTTP MCP server runs, its login code and the apps logged in")
142
- .description("whether `mcp --http` runs, on which port and address, the login code for a new browser app, and the apps logged in")
149
+ .description("whether `mcp serve --http` runs, on which port and address, the login code for a new browser app, and the apps logged in")
143
150
  .action(function () {
144
151
  const one = target(this);
145
152
  const lock = readHttpLock(httpLockFile(one.tokenFile));
@@ -320,6 +320,7 @@ export declare const meetingPreparationAnswer: (packet: MeetingPreparation) => i
320
320
  started_at: string | null;
321
321
  url: string | null;
322
322
  } | undefined;
323
+ untrusted?: string[] | undefined;
323
324
  }[];
324
325
  association: "unconfirmed-word-evidence";
325
326
  vocabulary_fingerprint: string;
@@ -40,6 +40,7 @@ export declare const mapPreparationProjectEvidence: (section: NonNullable<Meetin
40
40
  started_at: string | null;
41
41
  url: string | null;
42
42
  } | undefined;
43
+ untrusted?: string[] | undefined;
43
44
  }[];
44
45
  association: "unconfirmed-word-evidence";
45
46
  vocabulary_fingerprint: string;
@@ -349,6 +349,7 @@ export declare const readMeetingPreparation: (read: MeetingPreparationReader, in
349
349
  started_at: string | null;
350
350
  url: string | null;
351
351
  } | undefined;
352
+ untrusted?: string[] | undefined;
352
353
  }[];
353
354
  association: "unconfirmed-word-evidence";
354
355
  vocabulary_fingerprint: string;
@@ -139,6 +139,7 @@ export const topicAnswer = (topic) => defined({
139
139
  unread_count: topic.unreadCount,
140
140
  last_message_at: topic.lastMessageAt,
141
141
  created_at: topic.createdAt,
142
+ untrusted: ["title"],
142
143
  });
143
144
  export const folderAnswer = (folder) => {
144
145
  const shown = "chats" in folder ? folder : undefined;
@@ -17,11 +17,10 @@ export interface McpFlags {
17
17
  http?: boolean;
18
18
  port?: string;
19
19
  publicUrl?: string;
20
- revoke?: boolean;
21
20
  }
22
- /** The token file of `<tool> mcp --http` for every profile: no profile name can start with `_`. */
21
+ /** The token file of `<tool> mcp serve --http` for every profile: no profile name can start with `_`. */
23
22
  export declare const ALL_PROFILES = "_all";
24
- /** Where `mcp --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
23
+ /** Where `mcp serve --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
25
24
  export declare const httpTokenFile: (app: AppIdentity, profile: string, env: NodeJS.ProcessEnv) => string;
26
25
  export declare const mcpCommand: (messenger: Messenger) => Command;
27
26
  /**
@@ -44,7 +43,7 @@ export declare const redactTail: (text: string, home: string | undefined) => str
44
43
  * servers with a trimmed environment leaves it out (measured 2026-09-28). Credentials are never copied.
45
44
  */
46
45
  export declare const serverEntry: (app: AppIdentity, { profile, flags, execPath, scriptPath, env, }: {
47
- /** Left out, the entry is `<tool> mcp`, the server of every profile. */
46
+ /** Left out, the entry is `<tool> mcp serve`, the server of every profile. */
48
47
  profile?: string;
49
48
  flags?: McpFlags;
50
49
  execPath: string;