@wirecat/cli-core 0.566.0 → 0.568.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 (41) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/background/schedules.d.ts +35 -1
  3. package/dist/background/schedules.js +74 -8
  4. package/dist/cli/bot/command.js +5 -2
  5. package/dist/cli/bot/context.d.ts +2 -2
  6. package/dist/cli/bot/context.js +2 -2
  7. package/dist/cli/effects.d.ts +10 -0
  8. package/dist/cli/effects.js +19 -0
  9. package/dist/cli/index.d.ts +4 -3
  10. package/dist/cli/index.js +3 -2
  11. package/dist/cli/messenger/account-command.js +8 -2
  12. package/dist/cli/messenger/mcp-command.js +4 -1
  13. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  14. package/dist/cli/program.js +2 -2
  15. package/dist/cli/schedule/schedule-command.d.ts +23 -4
  16. package/dist/cli/schedule/schedule-command.js +107 -22
  17. package/dist/cli/settings.d.ts +9 -1
  18. package/dist/cli/settings.js +31 -5
  19. package/dist/entities/account-profile.d.ts +2 -0
  20. package/dist/entities/account-profile.js +2 -0
  21. package/dist/entities/index.d.ts +1 -0
  22. package/dist/errors/reasons.js +4 -1
  23. package/dist/mail/cli/marks.d.ts +2 -1
  24. package/dist/mail/cli/marks.js +13 -2
  25. package/dist/mail/cli/review.js +7 -5
  26. package/dist/mail/index.d.ts +1 -1
  27. package/dist/mail/index.js +1 -1
  28. package/dist/mail/marks.d.ts +21 -5
  29. package/dist/mail/marks.js +81 -26
  30. package/dist/meetings/findings-rules.d.ts +2 -2
  31. package/dist/meetings/findings-rules.js +15 -17
  32. package/dist/ports/app.d.ts +2 -0
  33. package/dist/ports/app.js +2 -0
  34. package/dist/services/proposals/kinds/mail-findings.d.ts +3 -2
  35. package/dist/services/proposals/kinds/mail-findings.js +3 -2
  36. package/dist/store/sqlite/mail-findings.js +10 -7
  37. package/dist/store/sqlite/work-tasks.js +1 -1
  38. package/package.json +1 -1
  39. package/parity.json +10 -0
  40. /package/dist/cli/{meeting-findings-input.d.ts → findings-input.d.ts} +0 -0
  41. /package/dist/cli/{meeting-findings-input.js → findings-input.js} +0 -0
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { CliError, resolvePaths, writeSecurely } from "@wirecat/cli-base";
4
4
  import { annotate } from "@wirecat/cli-base/commands";
5
5
  import { Argument, Command, Option } from "commander";
6
- import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, runSchedule, scheduleFile, scheduleName, timerFor, } from "../../background/schedules.js";
6
+ import { describeWhen, findProgram, lastRun, listSchedules, readSchedule, recordSkip, runSchedule, SCHEDULE_DEFAULTS, scheduleFile, scheduleName, spentToday, timerFor, } from "../../background/schedules.js";
7
7
  import { thisMachine } from "../../background/system.js";
8
8
  import { locationVariables } from "../../background/units.js";
9
9
  import { parseDuration } from "../../domain/duration.js";
@@ -76,12 +76,44 @@ const withWhen = (command) => command
76
76
  const promptExample = (app) => app.messenger === false
77
77
  ? `summarize what came in during the last day: ${app.command} personal search messages 'date>1d' --newest --limit 20`
78
78
  : `run the daily follow-up review: ${app.command} skill show follow-ups`;
79
+ /** The agent-schedule limits a host with `newMail` writes into its config file's top level (WC-CMD-30). */
80
+ export const SCHEDULE_SETTINGS = [
81
+ {
82
+ key: "schedule_mail_wait",
83
+ default: `${SCHEDULE_DEFAULTS.mailWaitMinutes}m`,
84
+ description: "an agent gated on new mail starts once the oldest waiting email has waited this long",
85
+ },
86
+ {
87
+ key: "schedule_mail_batch",
88
+ default: SCHEDULE_DEFAULTS.mailBatch,
89
+ description: "or once this many emails wait for it",
90
+ },
91
+ {
92
+ key: "schedule_max_cost_per_day",
93
+ default: SCHEDULE_DEFAULTS.maxCostPerDay,
94
+ description: "US dollars an agent schedule's runs may cost in a day before it waits for the next; 0 for no cap",
95
+ },
96
+ ];
97
+ /** `SCHEDULE_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
98
+ export const scheduleLimitsOf = (config) => {
99
+ const { schedule_mail_wait: wait, schedule_mail_batch: batch, schedule_max_cost_per_day: cap } = config;
100
+ return {
101
+ ...(typeof wait === "string"
102
+ ? { mailWaitMinutes: Math.ceil(parseDuration(wait, "schedule_mail_wait") / 60_000) }
103
+ : {}),
104
+ ...(typeof batch === "number" && Number.isSafeInteger(batch) && batch >= 1 ? { mailBatch: batch } : {}),
105
+ ...(typeof cap === "number" && Number.isFinite(cap) && cap >= 0 ? { maxCostPerDay: cap } : {}),
106
+ };
107
+ };
108
+ const given = (limits) => Object.fromEntries(Object.entries(limits).filter(([, value]) => value !== undefined));
109
+ const usd = (value) => `$${value.toFixed(2)}`;
79
110
  /**
80
111
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
81
112
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
82
113
  * the rest, so what runs and with which variables is the same on all three.
83
114
  */
84
- export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
115
+ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: configured, agentRuns = [], }) => {
116
+ const agentClis = [app, ...agentRuns];
85
117
  const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
86
118
  const prepare = (self, resolving = {}) => {
87
119
  const context = contextOf(self, resolving);
@@ -117,12 +149,44 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
117
149
  };
118
150
  return { context, system, state, say, timer, environment, save };
119
151
  };
120
- /** A schedule gated on new mail waits while nothing is stored past its holder's mark. */
121
- const waits = async (self, file) => {
122
- const holder = existsSync(file) ? readSchedule(file).onNewMail : undefined;
123
- if (holder === undefined || !newMail || (await newMail(self, holder)))
152
+ /**
153
+ * Why a run does not start now, or nothing: an agent whose runs reached the day's cost cap waits for tomorrow, and
154
+ * one gated on mail waits for mail past its holder's mark — on its timer, until enough of it waits.
155
+ */
156
+ const waits = async (self, file, { timed }) => {
157
+ if (!existsSync(file))
158
+ return undefined;
159
+ const schedule = readSchedule(file);
160
+ const { mailWaitMinutes, mailBatch, maxCostPerDay } = {
161
+ ...SCHEDULE_DEFAULTS,
162
+ ...given(configured?.(self) ?? {}),
163
+ ...given({
164
+ mailWaitMinutes: schedule.mailWaitMinutes,
165
+ mailBatch: schedule.mailBatch,
166
+ maxCostPerDay: schedule.maxCostPerDay,
167
+ }),
168
+ };
169
+ if (schedule.kind === "agent" && maxCostPerDay > 0) {
170
+ const spent = spentToday(file);
171
+ if (spent >= maxCostPerDay) {
172
+ const why = `today's runs cost ${usd(spent)}, the ${usd(maxCostPerDay)} a day allowed; it runs again tomorrow`;
173
+ recordSkip(file, why);
174
+ return why;
175
+ }
176
+ }
177
+ const holder = schedule.onNewMail;
178
+ if (holder === undefined || !newMail)
179
+ return undefined;
180
+ const waiting = await newMail(self, holder);
181
+ if (waiting.unmarked)
124
182
  return undefined;
125
- return holder;
183
+ if (waiting.emails === 0)
184
+ return `no mail stored past ${holder}'s mark`;
185
+ const waited = waiting.oldestStoredAt === null ? 0 : Date.now() - waiting.oldestStoredAt;
186
+ if (!timed || waiting.emails >= mailBatch || waited >= mailWaitMinutes * 60_000)
187
+ return undefined;
188
+ return (`${waiting.emails} email(s) wait past ${holder}'s mark; it starts at ${mailBatch}, ` +
189
+ `or once the oldest has waited ${mailWaitMinutes}m`);
126
190
  };
127
191
  withWhen(grantsAuthority(annotate(command.command("command"), {
128
192
  literal_after_dashes: true,
@@ -154,19 +218,31 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
154
218
  `${app.command} personal schedule agent triage codex "list what needs an answer today" --every 2h --max-cost 1`,
155
219
  ],
156
220
  }))
157
- .description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${app.envPrefix}_READ_ONLY=1, so it reads and drafts and cannot send — its answer is kept and you are notified`)
221
+ .description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${agentClis.map((one) => `${one.envPrefix}_READ_ONLY=1`).join(" and ")}, so it reads and drafts and cannot send — its answer is kept and you are notified`)
158
222
  .argument("<name>", "lowercase letters, digits and dashes")
159
223
  .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
160
224
  .argument("<prompt>", `what to do, e.g. "${promptExample(app)}"`)
161
- .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))).action(async function (name, harness, prompt) {
225
+ .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))
226
+ .addOption(new Option("--max-cost-per-day <usd>", `claude only: start no run for the rest of the day once its runs cost this much, in US dollars; 0 for no cap (default ${SCHEDULE_DEFAULTS.maxCostPerDay}, or the config file's)`).argParser(Number))).action(async function (name, harness, prompt) {
162
227
  const { context, system, environment, save } = prepare(this);
163
228
  const options = this.opts();
164
229
  if (options.onNewMail !== undefined && !HOLDER.test(options.onNewMail))
165
230
  throw new CliError("validation_error", `--on-new-mail takes a holder: lower-case letters, digits and dashes`);
231
+ if ((options.mailWait !== undefined || options.mailBatch !== undefined) && options.onNewMail === undefined)
232
+ throw new CliError("validation_error", "--mail-wait and --mail-batch shape a run gated by --on-new-mail");
233
+ if (options.maxCostPerDay !== undefined && !(Number.isFinite(options.maxCostPerDay) && options.maxCostPerDay >= 0))
234
+ throw new CliError("validation_error", "--max-cost-per-day takes US dollars, 0 or more");
235
+ const mailBatch = options.mailBatch === undefined ? undefined : Number(options.mailBatch);
236
+ if (mailBatch !== undefined && !(Number.isSafeInteger(mailBatch) && mailBatch >= 1))
237
+ throw new CliError("validation_error", "--mail-batch takes a count of emails, 1 or more");
238
+ const mailWaitMinutes = options.mailWait === undefined ? undefined : Math.ceil(parseDuration(options.mailWait, "--mail-wait") / 60_000);
166
239
  const program = findProgram(harness, context.env, system.platform);
167
240
  if (!program)
168
241
  throw new CliError("not_found", `${harness} is not on PATH`);
169
- const paths = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env });
242
+ const folders = agentClis.flatMap((one) => {
243
+ const paths = resolvePaths({ appName: one.appName, prefix: one.envPrefix, env: context.env });
244
+ return [paths.state, paths.config, paths.cache];
245
+ });
170
246
  const argv = harness === "claude"
171
247
  ? [
172
248
  program,
@@ -176,7 +252,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
176
252
  "--permission-mode",
177
253
  "dontAsk",
178
254
  "--allowedTools",
179
- `Bash(${app.command}:*)`,
255
+ ...agentClis.map((one) => `Bash(${one.command}:*)`),
180
256
  "--no-session-persistence",
181
257
  ...(options.maxCost ? ["--max-budget-usd", String(options.maxCost)] : []),
182
258
  ]
@@ -187,13 +263,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
187
263
  "--ephemeral",
188
264
  "--sandbox",
189
265
  "workspace-write",
190
- // The CLI reaches the messenger and writes its store and state, and nothing else.
266
+ // The CLIs reach their provider and write the store and their own state, and nothing else.
191
267
  "-c",
192
268
  "sandbox_workspace_write.network_access=true",
193
- ...[dirname(storePath(context.env)), paths.state, paths.config, paths.cache].flatMap((dir) => [
194
- "--add-dir",
195
- dir,
196
- ]),
269
+ ...[dirname(storePath(context.env)), ...folders].flatMap((dir) => ["--add-dir", dir]),
197
270
  prompt,
198
271
  ];
199
272
  await save({
@@ -204,12 +277,17 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
204
277
  environment: environment(),
205
278
  createdAt: new Date().toISOString(),
206
279
  ...(options.onNewMail ? { onNewMail: options.onNewMail } : {}),
280
+ ...(mailWaitMinutes === undefined ? {} : { mailWaitMinutes }),
281
+ ...(mailBatch === undefined ? {} : { mailBatch }),
282
+ ...(options.maxCostPerDay === undefined ? {} : { maxCostPerDay: options.maxCostPerDay }),
207
283
  });
208
284
  });
209
285
  if (newMail)
210
286
  command.commands
211
287
  .find((one) => one.name() === "agent")
212
- ?.option("--on-new-mail <holder>", `run only when mail is stored past this holder's mark — after a sync brought new mail; may check every ${GATED_AGENT_MINIMUM_MINUTES}m`);
288
+ ?.option("--on-new-mail <holder>", `run only when mail is stored past this holder's mark — after a sync brought new mail; may check every ${GATED_AGENT_MINIMUM_MINUTES}m`)
289
+ .option("--mail-wait <duration>", `with --on-new-mail: start once the oldest waiting email has waited this long (default ${SCHEDULE_DEFAULTS.mailWaitMinutes}m, or the config file's)`)
290
+ .option("--mail-batch <n>", `with --on-new-mail: or once this many emails wait (default ${SCHEDULE_DEFAULTS.mailBatch}, or the config file's)`);
213
291
  withQuery(annotate(command.command("list"), {
214
292
  effects: LOCAL_READ,
215
293
  examples: [`${app.command} personal schedule list`, `${app.command} personal schedule list kind:agent`],
@@ -223,6 +301,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
223
301
  kind: schedule.kind,
224
302
  when: describeWhen(schedule.when),
225
303
  last: lastRun(scheduleFile(state, schedule.name)) ?? null,
304
+ ...(schedule.kind === "agent" ? { spentTodayUsd: spentToday(scheduleFile(state, schedule.name)) } : {}),
226
305
  }));
227
306
  const answer = listOf("schedule list", {
228
307
  items: rows.map((row) => snakeKeys(row)),
@@ -235,7 +314,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
235
314
  return "";
236
315
  }
237
316
  return rows
238
- .map(({ name, kind, when, last }) => [
317
+ .map(({ name, kind, when, last, spentTodayUsd }) => [
239
318
  name,
240
319
  kind,
241
320
  when,
@@ -246,6 +325,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
246
325
  `${last.failures} in a row${last.reason ? `: ${last.reason}` : ""} — ${last.output}`
247
326
  : "")
248
327
  : "not run yet",
328
+ ...(spentTodayUsd ? [`today ${usd(spentTodayUsd)}`] : []),
329
+ ...(last?.skipped
330
+ ? [`skipped ${last.skipped.at.slice(0, 16).replace("T", " ")}: ${last.skipped.why}`]
331
+ : []),
249
332
  ].join(" "))
250
333
  .join("\n");
251
334
  });
@@ -258,11 +341,12 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
258
341
  .argument("<name>", "the schedule's name, as `schedule list` shows it")
259
342
  .action(async function (name) {
260
343
  const { context, system, state, say, timer } = prepare(this);
261
- const holder = await waits(this, scheduleFile(state, scheduleName(name)));
262
- if (holder !== undefined)
263
- return say({ name, skipped: true, holder }, [`${name}: skipped — no mail stored past ${holder}'s mark`]);
344
+ const why = await waits(this, scheduleFile(state, scheduleName(name)), { timed: false });
345
+ if (why !== undefined)
346
+ return say({ name, skipped: true, why }, [`${name}: skipped — ${why}`]);
264
347
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
265
348
  timer: timer(),
349
+ agentRuns,
266
350
  ...(signal ? { signal } : {}),
267
351
  }));
268
352
  say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
@@ -293,10 +377,11 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
293
377
  .action(async function (file) {
294
378
  // A timer starts it with no profile named; the schedule's own environment carries the profile.
295
379
  const { context, system, timer } = prepare(this, { needsProfile: false });
296
- if ((await waits(this, file)) !== undefined)
380
+ if ((await waits(this, file, { timed: true })) !== undefined)
297
381
  return;
298
382
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
299
383
  timer: timer(),
384
+ agentRuns,
300
385
  untilUnlocked: true,
301
386
  ...(signal ? { signal } : {}),
302
387
  }));
@@ -156,8 +156,16 @@ export interface Settings extends AISettings {
156
156
  configFound: boolean;
157
157
  /** Profiles the configuration file names, whether or not anyone has logged in to them. */
158
158
  configuredProfiles: string[];
159
- /** Every profile on this computer — what `account list` shows and a missing profile's error lists. */
159
+ /** Every profile on this computer, bots included — what a missing profile's error lists. */
160
160
  profilesHere: string[];
161
+ /**
162
+ * The profiles here that are an account, not only a bot — what `account list` shows. A name only the file's bot
163
+ * section names, with no personal section, recorded login or messenger profile of its own, is a bot. Absent for an
164
+ * app that builds `Settings` itself; `profilesHere` stands in.
165
+ */
166
+ accountProfiles?: string[];
167
+ /** Every name with a bot: the file's bot section, or a bot's own files. `account list` marks these. */
168
+ botProfiles?: string[];
161
169
  /** Where each value came from — `config show` prints it. */
162
170
  sources: Record<string, Source>;
163
171
  /** This profile's entries and the shared defaults, for an adapter resolving its own fields with `first`. */
@@ -10,9 +10,10 @@ import { assertOverridesTighten, CEILING_KEY, COMMAND_PATH_KEY, LEVELS, layerPer
10
10
  import { DEFAULT_HISTORY_LIMITS, HISTORY_MAX_DAYS } from "../services/history-limits.js";
11
11
  import { storePath } from "../store/path.js";
12
12
  import { sharedConfigPath } from "../store/shared-config.js";
13
+ import { registryProfiles } from "./bot/registry.js";
13
14
  import { checkConfigText, issueMessage, readConfigFile, readConfigText, savedPermissionKeys, schemaIssues, settingPathOf, withoutPath, } from "./config-issues.js";
14
15
  import { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
15
- import { profilesHere } from "./messenger/accounts.js";
16
+ import { profilesHere, profilesWithAccounts } from "./messenger/accounts.js";
16
17
  import { usableProfileName } from "./profile.js";
17
18
  import { withSettingDefaults } from "./setting-defaults.js";
18
19
  import { permissionCeiling } from "./setup-answers.js";
@@ -246,7 +247,7 @@ export const settingsFor = (app, extension = {}) => {
246
247
  const declaredSettings = [...SHARED_SETTINGS, ...(extension.settings ?? [])]
247
248
  .filter(({ key, default: value }) => allSettings.includes(key) && v.safeParse(readSchema, { defaults: { [key]: value } }).success)
248
249
  .map((setting) => ({ ...setting, key: `defaults.${setting.key}` }));
249
- // `defaults` needs no field of its own, so it is the one section created: every key then shows (RISK-2125).
250
+ // `defaults` needs no field of its own, so it is the one section created: every key then shows.
250
251
  const withDefaults = (raw, except = []) => withSettingDefaults(isScope(raw) && !Object.hasOwn(raw, "defaults") ? { ...raw, defaults: {} } : raw, declaredSettings.filter(({ key }) => !except.includes(key)));
251
252
  const PROFILE = envName(app, "PROFILE");
252
253
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
@@ -298,6 +299,16 @@ export const settingsFor = (app, extension = {}) => {
298
299
  throw new CliError("validation_error", `name a profile: \`${app.command} <profile> …\` — profiles here: ${here.join(", ")} (or set ${PROFILE})`, { profiles: here });
299
300
  };
300
301
  const profilesOf = (config, env) => profilesHere(app, [...namedProfiles(config), ...(extension.profiles?.(env) ?? [])], env);
302
+ const botsOf = (config, env) => [...new Set([...Object.keys(config.bot?.profiles ?? {}), ...registryProfiles(app, env)])].sort();
303
+ // The shared `profiles` section is no sign of an account: a bot's owner may set something there for both sides.
304
+ const accountsOf = (config, env, bots) => {
305
+ const personal = new Set([
306
+ ...Object.keys(config.personal?.profiles ?? {}),
307
+ ...profilesWithAccounts(app, env),
308
+ ...(extension.profiles?.(env) ?? []),
309
+ ]);
310
+ return profilesOf(config, env).filter((name) => !bots.includes(name) || personal.has(name));
311
+ };
301
312
  const resolveSettings = (flags = {}, options = {}) => {
302
313
  const env = options.env ?? process.env;
303
314
  const configPath = configPathFor(options);
@@ -322,6 +333,7 @@ export const settingsFor = (app, extension = {}) => {
322
333
  ? unnamed(config, env, options.needsProfile !== false)
323
334
  : { value: named.value, from: named.from }, lock);
324
335
  const kind = options.kind ?? "personal";
336
+ const bots = botsOf(config, env);
325
337
  const name = usableProfileName(profile.value);
326
338
  const configured = config.profiles[name] ?? {};
327
339
  const shared = config.defaults ?? {};
@@ -429,6 +441,8 @@ export const settingsFor = (app, extension = {}) => {
429
441
  configFound: existsSync(configPath),
430
442
  configuredProfiles: namedProfiles(config),
431
443
  profilesHere: profilesOf(config, env),
444
+ accountProfiles: accountsOf(config, env, bots),
445
+ botProfiles: bots,
432
446
  sources: {
433
447
  ...ai.sources,
434
448
  profile: profile.from,
@@ -527,10 +541,22 @@ export const settingsFor = (app, extension = {}) => {
527
541
  if (kind !== "bot" && botOnlyKeys.includes(setting)) {
528
542
  throw new CliError("validation_error", `${setting} is a bot's setting — add --bot`);
529
543
  }
530
- const changed = structuredClone(raw);
531
- setAt(changed, at, level === undefined ? parseValue(value) : value.trim());
532
544
  const had = new Set(before.map((issue) => issue.at.join(".")));
533
- const added = schemaIssues(changed, readSchema, context(path)).find((issue) => !had.has(issue.at.join(".")) || issue.at.join(".") === at.join("."));
545
+ const tried = (typed) => {
546
+ const changed = structuredClone(raw);
547
+ setAt(changed, at, typed);
548
+ const added = schemaIssues(changed, readSchema, context(path)).find((issue) => !had.has(issue.at.join(".")) || issue.at.join(".") === at.join("."));
549
+ return { changed, added };
550
+ };
551
+ const parsed = level === undefined ? parseValue(value) : value.trim();
552
+ let { changed, added } = tried(parsed);
553
+ // `phone_number_id 100000000000001` parses as a number; a setting that takes text keeps the digits as typed,
554
+ // never through a number, which rounds any id past 2^53.
555
+ if (added && typeof parsed !== "string") {
556
+ const asText = tried(value.trim());
557
+ if (!asText.added)
558
+ ({ changed, added } = asText);
559
+ }
534
560
  if (added)
535
561
  throw new CliError("validation_error", `${setting} cannot be "${value}": ${added.what}`);
536
562
  const checked = v.safeParse(readSchema, changed);
@@ -6,4 +6,6 @@ export declare const accountProfile: v.StrictObjectSchema<{
6
6
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
7
7
  /** False for a profile with no session on this computer; absent when the messenger does not say. */
8
8
  readonly signed_in: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
9
+ /** True when the same name is a bot profile too (`<tool> <profile> bot …`); absent otherwise. */
10
+ readonly bot: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
9
11
  }, undefined>;
@@ -7,4 +7,6 @@ export const accountProfile = v.strictObject({
7
7
  name: v.nullable(text),
8
8
  /** False for a profile with no session on this computer; absent when the messenger does not say. */
9
9
  signed_in: v.optional(v.boolean()),
10
+ /** True when the same name is a bot profile too (`<tool> <profile> bot …`); absent otherwise. */
11
+ bot: v.optional(v.literal(true)),
10
12
  });
@@ -3885,6 +3885,7 @@ export declare const ENTITIES: {
3885
3885
  readonly account: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
3886
3886
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
3887
3887
  readonly signed_in: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
3888
+ readonly bot: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
3888
3889
  }, undefined>;
3889
3890
  readonly organization: v.StrictObjectSchema<{
3890
3891
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -58,6 +58,7 @@ const OTHERS = [
58
58
  validation("meeting-findings.dedicated-review", "review meeting findings through the dedicated findings command"),
59
59
  validation("evidence-findings.stale", "evidence findings changed since they were read; stage a new batch"),
60
60
  validation("mail-findings.stale", "mail findings cite mail that changed since it was read; stage a new batch"),
61
+ ["mail-findings.not-stored", "not_found", "a mail finding cites an email the store does not hold"],
61
62
  validation("mail-findings.partial", "a cited email is not stored whole yet; cite it once it is"),
62
63
  validation("evidence-findings.dedicated-review", "review evidence findings through the dedicated findings command"),
63
64
  validation("mail-findings.dedicated-review", "review mail findings through the dedicated findings command"),
@@ -66,6 +67,8 @@ const OTHERS = [
66
67
  ["extraction.invalid-proposals", "invalid_response", "the model's memory proposals could not be used"],
67
68
  validation("extraction.text-too-long", "the text is too long for memory extraction"),
68
69
  validation("file.locked", "another process holds the file's lock"),
70
+ validation("mail.cursor-ahead", "an acknowledge cursor is past the newest stored email"),
71
+ ["mail.holder-not-yours", "permission_error", "a run moves only the mark of the holder its schedule names"],
69
72
  ["mail.source-failed", "provider_error", "the mail server refused or did not complete a request"],
70
73
  ["mail.search-refused", "provider_error", "the mail server refused to search a folder"],
71
74
  [
@@ -235,7 +238,7 @@ const OTHERS = [
235
238
  validation("task.category-unknown", "the category is not in the task category list"),
236
239
  validation("task.claimed", "someone else holds the task"),
237
240
  validation("task.closed", "the task is already closed"),
238
- ["task.stale", "configuration_error", "the task changed since it was read; read it again"],
241
+ ["task.stale", "conflict", "the task changed since it was read; read it again"],
239
242
  validation("task.column", "the task beside which it moves is in another column than the status asked for"),
240
243
  validation("task.dependency-cycle", "the task would depend on itself"),
241
244
  validation("task.is-subtask", "a subtask moves to another project alone only when it is detached from its parent in the same write"),
@@ -22,6 +22,7 @@ export declare const markItem: (mark: MailMark) => v.InferOutput<typeof mailMark
22
22
  export declare const markText: (mark: MailMark) => string;
23
23
  /**
24
24
  * `messages acknowledge`: a holder says it is done with what `review --holder` gave it, and its mark moves there. It
25
- * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it.
25
+ * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it — for the holder its
26
+ * schedule names only: an email that talks its reader into acknowledging would otherwise silence another pipeline.
26
27
  */
27
28
  export declare const acknowledgeCommand: (mail: MailReads) => Command;
@@ -1,10 +1,13 @@
1
+ import { CliError } from "@wirecat/cli-base";
1
2
  import { annotate } from "@wirecat/cli-base/commands";
2
3
  import { Command } from "commander";
3
4
  import * as v from "valibot";
4
5
  import { answerEntity } from "../../cli/answer-entity.js";
5
- import { outputFor } from "../../cli/context.js";
6
+ import { actorOf } from "../../cli/author.js";
7
+ import { environmentOf, outputFor } from "../../cli/context.js";
6
8
  import { LOCAL_WRITE, proposesOnly } from "../../cli/effects.js";
7
9
  import { itemOf, renderAnswer } from "../../cli/envelope.js";
10
+ import { mailHolderVariable, readOnlyLockOf } from "../../ports/app.js";
8
11
  import { acknowledgeMark } from "../marks.js";
9
12
  const iso = (ms) => (ms === null ? null : new Date(ms).toISOString());
10
13
  /** A holder's mark as `--json`, `mail status` and MCP show it. */
@@ -38,7 +41,8 @@ export const markText = (mark) => `${mark.holder} through ${mark.throughId}${ma
38
41
  `updated ${iso(mark.updatedAt)}\n`;
39
42
  /**
40
43
  * `messages acknowledge`: a holder says it is done with what `review --holder` gave it, and its mark moves there. It
41
- * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it.
44
+ * only moves the holder's own bookkeeping forward, so a read-only scheduled run may do it — for the holder its
45
+ * schedule names only: an email that talks its reader into acknowledging would otherwise silence another pipeline.
42
46
  */
43
47
  export const acknowledgeCommand = (mail) => answerEntity(proposesOnly(annotate(new Command("acknowledge"), {
44
48
  effects: LOCAL_WRITE,
@@ -53,6 +57,13 @@ export const acknowledgeCommand = (mail) => answerEntity(proposesOnly(annotate(n
53
57
  .requiredOption("--cursor <cursor>", "the cursor `review --holder` gave")
54
58
  .action(async function () {
55
59
  const { holder, cursor } = this.opts();
60
+ const env = environmentOf(this).env ?? process.env;
61
+ const named = env[mailHolderVariable(mail.app)]?.trim();
62
+ const owner = actorOf(this) === "owner" && !readOnlyLockOf(mail.app, env);
63
+ if (named ? named !== holder : !owner)
64
+ throw new CliError("permission_error", named
65
+ ? `this run moves only ${named}'s mark, not ${holder}'s`
66
+ : `an agent moves only the mark of the holder its schedule names (schedule agent --on-new-mail ${holder}); the owner moves any`, { reason: "mail.holder-not-yours" });
56
67
  const mark = await mail.read(this, (store, mailbox) => acknowledgeMark(store, mailbox, holder, cursor));
57
68
  renderAnswer(this, outputFor(this), itemOf(markItem(mark)), () => markText(mark));
58
69
  });
@@ -90,14 +90,16 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
90
90
  const cut = found.chats.filter((chat) => chat.more);
91
91
  const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
92
92
  const turns = found.unanswered !== undefined || found.waiting !== undefined;
93
- const wholeThreads = cut.flatMap((chat) => step("mail-conversations.show", { conversation: ref(chat.id) }));
93
+ const [first, ...rest] = cut.map((chat) => ref(chat.id));
94
+ const wholeThread = first === undefined ? [] : step("mail-conversations.show", { conversation: first });
94
95
  const missing = [
95
- ...(cut.length > 0
96
+ ...(first !== undefined
96
97
  ? [
97
98
  {
98
99
  field: "items",
99
- why: `only the newest emails of ${cut.length} thread(s)`,
100
- ...(wholeThreads.length ? { step: "mail-conversations.show" } : {}),
100
+ why: `only the newest emails of ${cut.length} thread(s)` +
101
+ (rest.length ? `; read the others whole the same way: ${rest.join(", ")}` : ""),
102
+ ...(wholeThread.length ? { step: "mail-conversations.show" } : {}),
101
103
  },
102
104
  ]
103
105
  : []),
@@ -117,7 +119,7 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
117
119
  ...(!fresh && !turns
118
120
  ? step("mail-review.next", { query: `date>${quoted(found.complete ? found.until : found.since)}` })
119
121
  : []),
120
- ...wholeThreads,
122
+ ...wholeThread,
121
123
  ];
122
124
  return listOf("review", {
123
125
  items: read.map((message) => ({
@@ -9,7 +9,7 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
9
9
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
10
10
  export { type MailIdsInput, mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, cursorOf, HOLD_PARTIAL_MS, HOLDER, holderHasMail, MARK_BATCH, type MailMark, type MarkRead, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, mailWaiting, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
13
13
  export { type MailTurn, mailTurn } from "./owed.js";
14
14
  export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
15
15
  export { PARTIAL, type PartialOutcome } from "./partial.js";
@@ -9,7 +9,7 @@ export { dropMissing, tooManyGone } from "./gone.js";
9
9
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
10
10
  export { mailIds } from "./ids.js";
11
11
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
12
- export { acknowledgeMark, cursorOf, HOLD_PARTIAL_MS, HOLDER, holderHasMail, MARK_BATCH, markOf, marksOf, readPastMark, } from "./marks.js";
12
+ export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, } from "./marks.js";
13
13
  export { mailTurn } from "./owed.js";
14
14
  export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
15
15
  export { PARTIAL } from "./partial.js";
@@ -17,6 +17,8 @@ export interface MailMark {
17
17
  /** ms: when the email at `throughId` was received; for display. */
18
18
  throughAt: number | null;
19
19
  held: number[];
20
+ /** Emails skipped while their sender looked suspicious; offered again once the sender's score is cleared. */
21
+ suspicious: number[];
20
22
  read: number;
21
23
  skipped: Record<SkipReason, number>;
22
24
  /** ms. */
@@ -48,13 +50,27 @@ export declare const readPastMark: (store: MessageStore, mailbox: AccountKey, ho
48
50
  now?: number;
49
51
  limit?: number;
50
52
  }) => Promise<MarkRead>;
53
+ /** Mail past a holder's marks that would reach it: how many emails, and when the oldest of them was stored. */
54
+ export interface MailWaiting {
55
+ /** No mark yet in some mailbox: the holder's first run sets it. */
56
+ unmarked: boolean;
57
+ emails: number;
58
+ /** ms. */
59
+ oldestStoredAt: number | null;
60
+ }
61
+ /**
62
+ * What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
63
+ * agent, so a run with nothing new costs no model call. Bulk, envelopes, drafts, suspicious senders and partial mail
64
+ * do not count.
65
+ */
66
+ export declare const mailWaiting: (store: MessageStore, holder: string, now?: number) => Promise<MailWaiting>;
51
67
  /**
52
- * Whether the holder has mail to read in any stored mailbox — what a schedule asks before it starts the holder's
53
- * agent, so a run with nothing new costs no model call. Mail that is only skipped (bulk, envelopes, drafts,
54
- * suspicious senders) moves the holder's mark here, as the agent would have acknowledged it, or it would wait
55
- * forever behind the mark. A holder with no mark yet has mail to read: its first run sets the mark.
68
+ * Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — bulk,
69
+ * envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
70
+ * does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
71
+ * email the holder reads. A store busy with a sync is left alone: the next check moves it.
56
72
  */
57
- export declare const holderHasMail: (store: MessageStore, holder: string, now?: number) => Promise<boolean>;
73
+ export declare const advancePastSkipped: (store: MessageStore, holder: string, now?: number) => Promise<void>;
58
74
  /**
59
75
  * Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
60
76
  * still partial, or changed after the read (it may have become whole since), is held and comes with the next read.