@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,80 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.568.0 — 05.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **`holderHasMail` is gone: `mailWaiting` checks and `advancePastSkipped` moves.** `holderHasMail` read like a
15
+ check but moved the holder's mark on every schedule tick. `mailWaiting(store, holder)` only reads: how many emails
16
+ past the holder's marks would reach it and when the oldest was stored, or `unmarked`. `advancePastSkipped(store,
17
+ holder)` moves the mark over the mail at its head the holder would only skip, and leaves it for the next check when
18
+ a sync holds the store. `ScheduleHost.newMail` now returns `MailWaiting`. A host calls `advancePastSkipped`, then
19
+ `mailWaiting`.
20
+ - **A gated agent schedule waits for a batch of mail.** On its timer, `schedule agent --on-new-mail <holder>` starts
21
+ the agent once 10 emails wait for it or the oldest has waited 30 minutes; `--mail-wait <duration>` and
22
+ `--mail-batch <n>` change that per schedule, and a host's `ScheduleHost.limits` gives its config file's values
23
+ (`SCHEDULE_SETTINGS`, read with `scheduleLimitsOf`): flag, then config file, then default. `schedule run` by hand
24
+ still starts it for any mail. Its skipped answer names `why` instead of `holder`.
25
+ - **An agent schedule stops for the day at a cost cap.** A claude run is asked for its JSON answer, so its cost
26
+ (`total_cost_usd`) is kept in `last.json` (`costUsd`) and summed per local day in `spend.json`; the report kept is
27
+ the answer's text. Once a day's runs cost `--max-cost-per-day` (default 5 US dollars, or the config file's; 0 for
28
+ none), no run starts until the next day; `last.json` says why under `skipped`, and `schedule list` shows the day's
29
+ spend and the skip.
30
+ - **`messages acknowledge` moves only a holder the caller may move.** A run that `schedule agent --on-new-mail` started
31
+ gets `<PREFIX>_MAIL_HOLDER` and moves that holder's mark only; any other agent, or a run under the read-only lock,
32
+ is refused with `mail.holder-not-yours`; the owner moves any. A cursor past the newest stored email, or read in the
33
+ future, is refused with `mail.cursor-ahead`. A scheduled triage agent needs `--on-new-mail`.
34
+ - **`review --holder` leaves out suspicious senders, and now keeps them on the mark.** Their
35
+ emails stay on the holder's mark and come in a later review once the sender's score is cleared, instead of being
36
+ passed for good. This was listed under Added in the release that brought it; it changes what an existing caller
37
+ reads.
38
+ - **A mail findings citation of an email the store does not hold is `not_found`** with reason
39
+ `mail-findings.not-stored`, no longer `mail-findings.stale`, which told the agent to read the mailbox again. An email
40
+ deleted after the batch was staged still makes accepting it stale.
41
+ - **`parseMailFindingsJson`, `parseMeetingFindingsJson` and `parseEvidenceFindingsJson` live in
42
+ `src/cli/findings-input.ts`;** the exports from `@wirecat/cli-core/cli` are unchanged. `findingText` and
43
+ `findingFields` are the checks themselves, not wrappers.
44
+
45
+ ### Fixed
46
+
47
+ - **An accepted `new_contact` mail finding names the person.** Sync already names a sender's person from the From
48
+ display name, so accepting set nothing while the receipt said accepted; the owner's chosen name now replaces it.
49
+ - **`mail review` offers at most two next steps.** It gave one `conversations show` step per cut thread, up to 50;
50
+ it now gives one, and names the other cut threads under `missing`.
51
+
52
+ ## 0.567.0 — 05.11.2026
53
+
54
+ ### Added
55
+
56
+ - **`<tool> bot list` runs without a profile word**, as `account list` does; `<tool> <profile> bot list` still
57
+ works.
58
+ - **A scheduled agent may run other CLIs the host declares** (`ScheduleHost.agentRuns`, type `AgentCli`): memo's
59
+ mail triage runs `mail`. Claude gets `Bash(<cli>:*)` for each; codex may write each one's state, config and cache
60
+ folders; every run sets `<PREFIX>_READ_ONLY=1` and `<PREFIX>_SCHEDULED` for each, whatever the schedule file says.
61
+ `runSchedule` takes the same list as `agentRuns`.
62
+ - **`<PREFIX>_READ_ONLY` lets through one authority change, `rules add`, when the command is marked
63
+ `narrowsUnderLock`** (`LOCKED_AUTHORITY_COMMANDS`, `admittedUnderLock`). The command narrows itself under the lock;
64
+ every other command marked `grantsAuthority` is still refused, and MCP still needs `mcp.authority`.
65
+
66
+ ### Fixed
67
+
68
+ - **`account list` no longer lists a bot as an account that is not signed in.** A name that only the bot side
69
+ uses (the file's `bot` section, a bot's own files) and that has no personal section, recorded login or session is
70
+ left out; `bot list` has it. A profile that is an account and a bot too says `bot: true`, a new optional field of
71
+ `account_profile`. `mcp serve --profiles all` serves the same account profiles. `Settings` gains the optional
72
+ `accountProfiles` and `botProfiles`.
73
+ - **`config set` keeps a long id as the digits typed.** `config set --bot phone_number_id 100000000000001` was
74
+ parsed as a number and refused by a setting that takes text; when the parsed value is refused and the text as
75
+ typed passes, the text is kept, never through a JS number.
76
+
77
+ ### Changed — may break callers
78
+
79
+ - **A write on a stale task revision fails with `conflict`, exit 15, not `configuration_error`, exit 3.** The reason
80
+ stays `task.stale` with the task's current `revision` in `details`; only the code and the exit number change
81
+ (cli-base's new `conflict`). A caller that branched on `configuration_error` for it should branch on the reason or
82
+ the new code. `vocabulary.stale`, `tag-review.stale` and `meeting-project.stale` keep `configuration_error` for now.
83
+
10
84
  ## 0.566.0 — 05.11.2026
11
85
 
12
86
  ### Changed — may break callers
@@ -19,7 +19,29 @@ export interface Schedule {
19
19
  createdAt: string;
20
20
  /** A holder whose mark gates the run: it starts only when mail is stored past that mark. */
21
21
  onNewMail?: string;
22
+ /** Gated on mail: start once the oldest waiting email has waited this many minutes, or `mailBatch` emails wait. */
23
+ mailWaitMinutes?: number;
24
+ mailBatch?: number;
25
+ /** An agent's: no run starts for the rest of the local day once its runs cost this much, in US dollars. */
26
+ maxCostPerDay?: number;
22
27
  }
28
+ /** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
29
+ export declare const SCHEDULE_DEFAULTS: {
30
+ readonly mailWaitMinutes: 30;
31
+ readonly mailBatch: 10;
32
+ readonly maxCostPerDay: 5;
33
+ };
34
+ /** One local day's cost of a schedule's runs, as the harness reported it (`spend.json` beside the schedule). */
35
+ export interface DaySpend {
36
+ /** The local date, YYYY-MM-DD. */
37
+ day: string;
38
+ usd: number;
39
+ }
40
+ export declare const localDay: (at: Date) => string;
41
+ /** What the schedule's runs cost today; nothing before its first priced run of the day. */
42
+ export declare const spentToday: (file: string, now?: Date) => number;
43
+ /** Another CLI a scheduled agent may run by name, as the host declares it: `memo`'s agent runs `mail`. */
44
+ export type AgentCli = Pick<AppIdentity, "command" | "appName" | "envPrefix">;
23
45
  export interface LastRun {
24
46
  at: string;
25
47
  exitCode: number;
@@ -35,6 +57,13 @@ export interface LastRun {
35
57
  failingSince?: string;
36
58
  /** Failed runs in a row; absent once a run succeeds. */
37
59
  failures?: number;
60
+ /** What the run cost, as the harness reported it; absent when it reports none. */
61
+ costUsd?: number;
62
+ /** The last time the timer did not start it, and why; a run after it clears this. */
63
+ skipped?: {
64
+ at: string;
65
+ why: string;
66
+ };
38
67
  }
39
68
  /**
40
69
  * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
@@ -58,7 +87,10 @@ export declare const recordRun: (path: string, run: {
58
87
  traceId?: string;
59
88
  reason?: string;
60
89
  errorCode?: string;
90
+ costUsd?: number;
61
91
  }) => LastRun;
92
+ /** Notes on the last run that the timer left this one out, keeping that run's result. */
93
+ export declare const recordSkip: (file: string, why: string, at?: Date) => void;
62
94
  /**
63
95
  * One stderr line per failing background job, or nothing: `tg: scheduled digest failing since … (3 runs):
64
96
  * <reason> — tg schedule list`. A status the owner has to ask for is how a broken timer goes unnoticed.
@@ -113,11 +145,13 @@ export declare const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
113
145
  * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
114
146
  * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
115
147
  */
116
- export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, }?: {
148
+ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, agentRuns, }?: {
117
149
  now?: () => Date;
118
150
  timer?: Timer;
119
151
  signal?: AbortSignal;
120
152
  killAfterMs?: number;
121
153
  untilUnlocked?: boolean;
122
154
  wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
155
+ /** The other CLIs an agent may run; each runs under its own read-only lock and knows it runs from a schedule. */
156
+ agentRuns?: readonly AgentCli[];
123
157
  }) => Promise<LastRun>;
@@ -1,10 +1,54 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { CliError, errorCodes, writeSecurely } from "@wirecat/cli-base";
5
- import { readOnlyLockOf } from "../ports/app.js";
5
+ import { mailHolderVariable, readOnlyLockOf } from "../ports/app.js";
6
6
  import { recordLogFor } from "../runs/record-log.js";
7
7
  import { childTrace } from "../runs/scope.js";
8
+ /** What a gated agent waits for, and what a day of its runs may cost, when neither its flags nor the config say. */
9
+ export const SCHEDULE_DEFAULTS = { mailWaitMinutes: 30, mailBatch: 10, maxCostPerDay: 5 };
10
+ export const localDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`;
11
+ const spendFile = (file) => join(file, "..", "spend.json");
12
+ /** What the schedule's runs cost today; nothing before its first priced run of the day. */
13
+ export const spentToday = (file, now = new Date()) => {
14
+ try {
15
+ const spend = JSON.parse(readFileSync(spendFile(file), "utf8"));
16
+ return spend.day === localDay(now) && typeof spend.usd === "number" ? spend.usd : 0;
17
+ }
18
+ catch {
19
+ return 0;
20
+ }
21
+ };
22
+ const addSpend = (folder, usd, at) => {
23
+ const file = join(folder, "schedule.json");
24
+ const spend = { day: localDay(at), usd: spentToday(file, at) + usd };
25
+ writeFileSync(spendFile(file), `${JSON.stringify(spend)}\n`, { mode: 0o600 });
26
+ };
27
+ /**
28
+ * Claude reports a run's cost only in its JSON answer, so a scheduled claude run asks for it; the report kept is the
29
+ * answer's text. Codex reports none.
30
+ */
31
+ const priced = (argv) => {
32
+ const [program = ""] = argv;
33
+ const claude = /^claude(\.exe|\.cmd)?$/i.test(basename(program));
34
+ return claude && argv.includes("-p") && !argv.includes("--output-format")
35
+ ? [...argv, "--output-format", "json"]
36
+ : argv;
37
+ };
38
+ const reportOf = (stdout) => {
39
+ try {
40
+ const answer = JSON.parse(stdout.toString("utf8"));
41
+ if (answer?.type !== "result")
42
+ return { text: stdout };
43
+ return {
44
+ text: typeof answer.result === "string" ? Buffer.from(answer.result) : stdout,
45
+ ...(typeof answer.total_cost_usd === "number" ? { costUsd: answer.total_cost_usd } : {}),
46
+ };
47
+ }
48
+ catch {
49
+ return { text: stdout };
50
+ }
51
+ };
8
52
  const REASON_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/;
9
53
  /**
10
54
  * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
@@ -52,6 +96,14 @@ export const recordRun = (path, run) => {
52
96
  writeSecurely(path, `${JSON.stringify(last, null, 2)}\n`, 0o600);
53
97
  return last;
54
98
  };
99
+ /** Notes on the last run that the timer left this one out, keeping that run's result. */
100
+ export const recordSkip = (file, why, at = new Date()) => {
101
+ const path = join(file, "..", "last.json");
102
+ const before = readRun(path);
103
+ if (!before)
104
+ return;
105
+ writeSecurely(path, `${JSON.stringify({ ...before, skipped: { at: at.toISOString(), why } }, null, 2)}\n`, 0o600);
106
+ };
55
107
  const shortTime = (iso) => {
56
108
  const at = new Date(iso);
57
109
  const two = (n) => String(n).padStart(2, "0");
@@ -358,7 +410,7 @@ const pause = (ms, signal) => new Promise((resolve) => {
358
410
  * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
359
411
  * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
360
412
  */
361
- export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, } = {}) => {
413
+ export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, agentRuns = [], } = {}) => {
362
414
  const schedule = readSchedule(file);
363
415
  const folder = join(file, "..");
364
416
  const runs = join(folder, "runs");
@@ -366,12 +418,12 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
366
418
  const agent = schedule.kind === "agent";
367
419
  const retries = untilUnlocked && !("every" in schedule.when);
368
420
  const firstAt = now().getTime();
369
- let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
421
+ let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns });
370
422
  for (let delay = LOCKED_FIRST_WAIT_MS; retries && lockedOut(last) && !signal?.aborted && now().getTime() - firstAt + delay <= LOCKED_GIVE_UP_MS; delay = Math.min(delay * 2, LOCKED_LONGEST_WAIT_MS)) {
371
423
  await wait(delay, signal);
372
424
  if (signal?.aborted)
373
425
  break;
374
- last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
426
+ last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns });
375
427
  }
376
428
  const { exitCode, output } = last;
377
429
  const errors = agent ? output.replace(/\.md$/, ".log") : output;
@@ -399,7 +451,7 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
399
451
  return last;
400
452
  };
401
453
  /** One run of the schedule's command or agent: its output in `runs/`, its result in `last.json`. */
402
- const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs }) => {
454
+ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns, }) => {
403
455
  const runs = join(folder, "runs");
404
456
  const stamp = now()
405
457
  .toISOString()
@@ -408,15 +460,22 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
408
460
  const agent = schedule.kind === "agent";
409
461
  const output = join(runs, `${stamp}.${agent ? "md" : "log"}`);
410
462
  const errors = agent ? join(runs, `${stamp}.log`) : output;
411
- const [program = "", ...args] = agent ? schedule.argv : [...system.entry, ...schedule.argv];
463
+ const [program = "", ...args] = agent ? priced(schedule.argv) : [...system.entry, ...schedule.argv];
412
464
  const trace = childTrace();
413
465
  const childEnv = {
414
466
  ...env,
415
467
  ...schedule.environment,
416
468
  TRACEPARENT: trace.traceparent,
417
469
  [`${app.envPrefix}_SCHEDULED`]: schedule.name,
470
+ ...(schedule.onNewMail ? { [mailHolderVariable(app)]: schedule.onNewMail } : {}),
418
471
  // A run started under the lock stays under it, whatever the schedule file says.
419
472
  ...(agent || readOnlyLockOf(app, env) ? { [`${app.envPrefix}_READ_ONLY`]: "1" } : {}),
473
+ ...(agent
474
+ ? Object.fromEntries(agentRuns.flatMap((other) => [
475
+ [`${other.envPrefix}_READ_ONLY`, "1"],
476
+ [`${other.envPrefix}_SCHEDULED`, schedule.name],
477
+ ]))
478
+ : {}),
420
479
  };
421
480
  // The child's output is data, and an agent's is another program's: it is kept as it came. These lines say
422
481
  // only when it ran and how it ended.
@@ -424,6 +483,7 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
424
483
  const facts = { schedule: schedule.name, kind: schedule.kind };
425
484
  const began = now().getTime();
426
485
  log.info({ event: "schedule.start", ...facts });
486
+ let costUsd;
427
487
  const exitCode = await new Promise((resolve) => {
428
488
  const child = spawn(program, args, { env: childEnv, cwd: folder, stdio: ["ignore", "pipe", "pipe"] });
429
489
  const out = [];
@@ -448,24 +508,30 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
448
508
  signal?.removeEventListener("abort", stop);
449
509
  if (kill)
450
510
  clearTimeout(kill);
451
- writeFileSync(output, Buffer.concat(agent ? out : [...out, ...err]), { mode: 0o600 });
511
+ const report = agent ? reportOf(Buffer.concat(out)) : { text: Buffer.concat([...out, ...err]) };
512
+ costUsd = report.costUsd;
513
+ writeFileSync(output, report.text, { mode: 0o600 });
452
514
  if (agent)
453
515
  writeFileSync(errors, Buffer.concat(err), { mode: 0o600 });
454
516
  resolve(code ?? (killedBy === "SIGKILL" ? 137 : killedBy === "SIGTERM" ? 143 : 1));
455
517
  });
456
518
  });
457
519
  const ended = now();
520
+ if (costUsd !== undefined)
521
+ addSpend(folder, costUsd, ended);
458
522
  log[exitCode === 0 ? "info" : "error"]({
459
523
  event: "schedule.exit",
460
524
  ...facts,
461
525
  exitCode,
462
526
  durationMs: ended.getTime() - began,
527
+ ...(costUsd === undefined ? {} : { costUsd }),
463
528
  });
464
529
  return recordRun(join(folder, "last.json"), {
465
530
  at: ended.toISOString(),
466
531
  exitCode,
467
532
  output,
468
533
  traceId: trace.traceId,
534
+ ...(costUsd === undefined ? {} : { costUsd }),
469
535
  ...(exitCode === 0 ? {} : failureOf(readFileSync(errors, "utf8"), exitCode)),
470
536
  });
471
537
  };
@@ -119,13 +119,16 @@ const meCommand = (bot) => annotate(new Command("me"), { effects: REMOTE_READ, e
119
119
  }
120
120
  });
121
121
  });
122
- const listCommand = (bot, tokenVariable) => withQuery(annotate(new Command("list"), { effects: LOCAL_READ, examples: botExamples(bot, "list", "list --check") }), BOT_PROFILES_QUERY)
122
+ const listCommand = (bot, tokenVariable) => withQuery(annotate(new Command("list"), {
123
+ effects: LOCAL_READ,
124
+ examples: [`${bot.app.command} bot list`, `${bot.app.command} bot list --check`],
125
+ }), BOT_PROFILES_QUERY)
123
126
  .description(`every name on this machine that has a bot token; --check asks ${bot.name ?? "the messenger"} which bot each is`)
124
127
  .summary("every bot profile on this machine; --check asks which bot each is")
125
128
  .option("--check", "ask the messenger who each bot is, with its token")
126
129
  .action(async function (words) {
127
130
  const wanted = botProfileName(words);
128
- const context = botContext(this, bot);
131
+ const context = botContext(this, bot, { needsProfile: false });
129
132
  const check = this.opts().check === true;
130
133
  if (check)
131
134
  online(context, this);
@@ -2,14 +2,14 @@ import type { Command } from "commander";
2
2
  import { SendJournal } from "../../sends/journal.js";
3
3
  import { type Level, type PermissionKey } from "../../sends/permissions.js";
4
4
  import { RecipientList } from "../../sends/recipients.js";
5
- import type { Settings } from "../settings.js";
5
+ import type { ResolveOptions, Settings } from "../settings.js";
6
6
  import type { BotAdapter, BotChatRef, BotConnectOptions, BotMessenger } from "./port.js";
7
7
  import { ChatRegistry } from "./registry.js";
8
8
  import { BotTokenStore } from "./token.js";
9
9
  /** How the owner types this bot — `max sales bot` — so a refusal names the command that fixes it. */
10
10
  export declare const botWords: (bot: BotMessenger, profile: string) => string;
11
11
  /** What a bot command needs, resolved once: the bot's settings, its token, its lists. */
12
- export declare const botContext: (command: Command, bot: BotMessenger) => {
12
+ export declare const botContext: (command: Command, bot: BotMessenger, resolving?: Pick<ResolveOptions, "needsProfile">) => {
13
13
  settings: Settings;
14
14
  renderer: import("@wirecat/cli-base").Renderer;
15
15
  format: import("@wirecat/cli-base").RenderFormat;
@@ -28,8 +28,8 @@ const refuseBotPermissions = (command, bot, settings) => {
28
28
  }
29
29
  };
30
30
  /** What a bot command needs, resolved once: the bot's settings, its token, its lists. */
31
- export const botContext = (command, bot) => {
32
- const base = baseContext(command, (flags, options) => bot.resolveSettings(flags, { ...options, kind: "bot" }));
31
+ export const botContext = (command, bot, resolving = {}) => {
32
+ const base = baseContext(command, (flags, options) => bot.resolveSettings(flags, { ...options, ...resolving, kind: "bot" }));
33
33
  const { settings, env, keyring } = base;
34
34
  const profile = settings.profile;
35
35
  const files = botFiles(bot.app, profile, env);
@@ -49,6 +49,16 @@ export declare const PROPOSE_COMMANDS: readonly ["messages acknowledge", "mail f
49
49
  /** Marks a command of the `propose` tier; the lock lets it through only when its path is in `PROPOSE_COMMANDS`. */
50
50
  export declare const proposesOnly: <T extends Command>(command: T) => T;
51
51
  export declare const isProposal: (command: Command) => boolean;
52
+ /**
53
+ * The authority changes `<PREFIX>_READ_ONLY` lets through, a closed list: `mail rules add`, which the owner lets the
54
+ * scheduled mail triage run. Such a command is marked `narrowsUnderLock` **and** has one of these paths, and under the
55
+ * lock it narrows itself: it only adds what the owner can list and remove, and replaces nothing. Adding a path widens
56
+ * what a read-only agent decides for later runs, so it needs its own review.
57
+ */
58
+ export declare const LOCKED_AUTHORITY_COMMANDS: readonly ["rules add"];
59
+ /** Marks an authority change that checks the lock itself; the lock lets it through only when its path is listed. */
60
+ export declare const narrowsUnderLock: <T extends Command>(command: T) => T;
61
+ export declare const admittedUnderLock: (command: Command) => boolean;
52
62
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
53
63
  export declare const changesStore: <T extends Command>(command: T) => T;
54
64
  /**
@@ -79,6 +79,25 @@ export const isProposal = (command) => {
79
79
  const path = pathOf(command).join(" ");
80
80
  return PROPOSE_COMMANDS.some((listed) => path === listed || path.endsWith(` ${listed}`));
81
81
  };
82
+ /**
83
+ * The authority changes `<PREFIX>_READ_ONLY` lets through, a closed list: `mail rules add`, which the owner lets the
84
+ * scheduled mail triage run. Such a command is marked `narrowsUnderLock` **and** has one of these paths, and under the
85
+ * lock it narrows itself: it only adds what the owner can list and remove, and replaces nothing. Adding a path widens
86
+ * what a read-only agent decides for later runs, so it needs its own review.
87
+ */
88
+ export const LOCKED_AUTHORITY_COMMANDS = ["rules add"];
89
+ const NARROWS = new WeakSet();
90
+ /** Marks an authority change that checks the lock itself; the lock lets it through only when its path is listed. */
91
+ export const narrowsUnderLock = (command) => {
92
+ NARROWS.add(command);
93
+ return command;
94
+ };
95
+ export const admittedUnderLock = (command) => {
96
+ if (!NARROWS.has(command))
97
+ return false;
98
+ const path = pathOf(command).join(" ");
99
+ return LOCKED_AUTHORITY_COMMANDS.some((listed) => path === listed || path.endsWith(` ${listed}`));
100
+ };
82
101
  const STORE_WRITES = new WeakSet();
83
102
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
84
103
  export const changesStore = (command) => {
@@ -1,4 +1,5 @@
1
1
  export { AI_ENTRIES, AI_SETTING_KEYS, type AISettings, resolveAISettings } from "../analysis/settings.js";
2
+ export type { AgentCli } from "../background/schedules.js";
2
3
  export { parseDuration } from "../domain/duration.js";
3
4
  export { openOf, PROFILE_PLACEHOLDER, type ProfilesFor, type RefOpen, type RefSummary, refLabel, } from "../domain/ref-summaries.js";
4
5
  export { botTemplate } from "../entities/list-results.js";
@@ -70,10 +71,11 @@ export { sharedCommandTemplate } from "./contract-templates.js";
70
71
  export { type Closeable, withDeadline } from "./deadline.js";
71
72
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
72
73
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
73
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
74
+ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, LOCKED_AUTHORITY_COMMANDS, logsInOrOut, NO_EFFECTS, narrowsUnderLock, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
74
75
  export { type CliEntry, type EntryContext, type EntryIo, openBrowser, startCli } from "./entry.js";
75
76
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
76
77
  export { withAbort } from "./execution.js";
78
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./findings-input.js";
77
79
  export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, setupAtStart, systemLanguage, } from "./first-run.js";
78
80
  export { checkGuide, checkStep, type GuideOptions, type GuideReference, type GuideStep, loadScenarios, MEMO_SCENARIOS, type Scenario, shellProblems, } from "./guide.js";
79
81
  export { type CommandHelp, commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, type NextChoice, nextUsually, } from "./help/command-help.js";
@@ -86,7 +88,6 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
86
88
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
87
89
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
88
90
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
89
- export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
90
91
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
91
92
  export { type MeetingPreparationReader, meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
92
93
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
@@ -119,7 +120,7 @@ export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEv
119
120
  export { type ProjectStatusHints, type ProjectStatusItem, projectStatusAnswer, projectStatusRow, projectStatusSchema, projectStatusText, } from "./project-status.js";
120
121
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
121
122
  export { fieldsOf, projectFields } from "./result-fields.js";
122
- export type { ScheduleHost } from "./schedule/schedule-command.js";
123
+ export { SCHEDULE_SETTINGS, type ScheduleHost, type ScheduleLimits, scheduleLimitsOf, } from "./schedule/schedule-command.js";
123
124
  export { type DeclaredSetting, type SettingRow, settingRows, withSettingDefaults } from "./setting-defaults.js";
124
125
  export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
125
126
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
package/dist/cli/index.js CHANGED
@@ -62,10 +62,11 @@ export { sharedCommandTemplate } from "./contract-templates.js";
62
62
  export { withDeadline } from "./deadline.js";
63
63
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
64
64
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
65
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
65
+ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isProposal, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, LOCKED_AUTHORITY_COMMANDS, logsInOrOut, NO_EFFECTS, narrowsUnderLock, ownerOnlyOption, PROPOSE_COMMANDS, previewFlag, proposesOnly, REMOTE_READ, REMOTE_WRITE, reachesOtherService, readsOverMcp, readsOverMcpAt, refuseUnderReadOnly, storeChangeRefusal, writesFileOnlyWith, } from "./effects.js";
66
66
  export { openBrowser, startCli } from "./entry.js";
67
67
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
68
68
  export { withAbort } from "./execution.js";
69
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./findings-input.js";
69
70
  export { askSetupQuestions, asksAtStart, setupAtStart, systemLanguage, } from "./first-run.js";
70
71
  export { checkGuide, checkStep, loadScenarios, MEMO_SCENARIOS, shellProblems, } from "./guide.js";
71
72
  export { commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, nextUsually, } from "./help/command-help.js";
@@ -78,7 +79,6 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
78
79
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
79
80
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
80
81
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
81
- export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
82
82
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
83
83
  export { meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
84
84
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
@@ -109,6 +109,7 @@ export { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWind
109
109
  export { projectStatusAnswer, projectStatusRow, projectStatusSchema, projectStatusText, } from "./project-status.js";
110
110
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
111
111
  export { fieldsOf, projectFields } from "./result-fields.js";
112
+ export { SCHEDULE_SETTINGS, scheduleLimitsOf, } from "./schedule/schedule-command.js";
112
113
  export { settingRows, withSettingDefaults } from "./setting-defaults.js";
113
114
  export { count, first, flag, fromFile, NONE_NAMED, namedProfile, plain, settingsFor, } from "./settings.js";
114
115
  export { readSetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
@@ -22,13 +22,18 @@ export const accountCommand = (messenger) => {
22
22
  .description("the logged-in account")
23
23
  .addCommand(withQuery(annotate(new Command("list"), { effects: REMOTE_READ, examples: [`${tool} account list`] }), ACCOUNT_PROFILES_QUERY)
24
24
  .summary("profiles on this computer and their signed-in accounts")
25
- .description("every profile on this computer, and the account each is logged in as; asks the messenger nothing")
25
+ .description("every account profile on this computer, and the account each is logged in as; a name that is only a bot " +
26
+ "is left out (`bot list` has it), one that is a bot too says `bot: true`; asks the messenger nothing")
26
27
  .action(async function (words) {
27
28
  const terms = queryTerms(words, ACCOUNT_PROFILES_QUERY, "account list");
28
29
  const context = messengerContext(this, messenger, { needsProfile: false });
29
30
  const { app, provider } = messenger;
30
31
  const current = context.settings.sources.profile === NONE_NAMED ? undefined : context.settings.profile;
31
- const profiles = [...new Set([...(current === undefined ? [] : [current]), ...context.settings.profilesHere])]
32
+ const { profilesHere, accountProfiles = profilesHere, botProfiles = [] } = context.settings;
33
+ const isAccount = (profile) => accountProfiles.includes(profile) || messenger.profileState?.(profile, context.env) === "signed-in";
34
+ const profiles = [
35
+ ...new Set([...(current === undefined ? [] : [current]), ...profilesHere.filter(isAccount)]),
36
+ ]
32
37
  .filter((profile) => terms.get("profile") === undefined || profile === terms.get("profile"))
33
38
  .filter((profile) => terms.get("current") === undefined ||
34
39
  (profile === current) === (terms.get("current")?.toLowerCase() === "true"))
@@ -48,6 +53,7 @@ export const accountCommand = (messenger) => {
48
53
  ...(messenger.profileState
49
54
  ? { signed_in: messenger.profileState(profile, context.env) !== "signed-out" }
50
55
  : {}),
56
+ ...(botProfiles.includes(profile) ? { bot: true } : {}),
51
57
  });
52
58
  }
53
59
  renderAnswer(this, context, wholeList("account list", items));
@@ -56,7 +56,10 @@ export const servedProfiles = (self, messenger, { settings, env }, given, words,
56
56
  if (lock)
57
57
  return { set: { option: lock, profiles: () => [lock], key: `_${lock}` } };
58
58
  if (names.length === 1 && names[0] === EVERY_PROFILE) {
59
- const every = () => messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true }).profilesHere;
59
+ const every = () => {
60
+ const here = messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true });
61
+ return here.accountProfiles ?? here.profilesHere;
62
+ };
60
63
  return { set: { option: EVERY_PROFILE, profiles: every, key: ALL_PROFILES } };
61
64
  }
62
65
  if (names.length === 0 || names.includes(EVERY_PROFILE))
@@ -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;
@@ -21,7 +21,7 @@ import { commandPathOf } from "./command-contract.js";
21
21
  import { entityRows } from "./compact.js";
22
22
  import { outputFor, provide } from "./context.js";
23
23
  import { refuseByCommandPermissions, refuseWriteBeforeConnecting } from "./early-refusal.js";
24
- import { changesAuthorityAt, changesAuthorityNow, isLogin, isStoreChange, judgedEffects, judgedTargets, storeChangeRefusal, } from "./effects.js";
24
+ import { admittedUnderLock, changesAuthorityAt, changesAuthorityNow, isLogin, isStoreChange, judgedEffects, judgedTargets, storeChangeRefusal, } from "./effects.js";
25
25
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
26
26
  import { byteCount, DEFAULT_AGENT_COOPERATIVE_MS, DEFAULT_COMMAND_MS, DEFAULT_COOPERATIVE_MS, DEFAULT_OUTPUT_BYTES, DEFAULT_SYNC_MS, execution, } from "./execution.js";
27
27
  import { setupAtStart } from "./first-run.js";
@@ -334,7 +334,7 @@ const runOnce = async (argv, definition, options) => {
334
334
  if (options.relayed && isLogin(action))
335
335
  throw new CliError("permission_error", `\`${command} ${path.join(" ")}\` logs the account in or out, which serve never runs for a client — stop ` +
336
336
  `serve first (\`${command} ${options.relayed.profile} server stop\`), or update ${command}, which then runs it here`, { sent: false });
337
- if (lock && changesAuthorityNow(action) && !flags.dryRun)
337
+ if (lock && changesAuthorityNow(action) && !admittedUnderLock(action) && !flags.dryRun)
338
338
  throw new CliError("permission_error", `this process is read-only (${lock}) — \`${command} ${path.join(" ")}\` changes what a later run may do; ` +
339
339
  `run it without ${lock}`);
340
340
  if (options.agent && options.agent.authority !== true && changesAuthorityNow(action))
@@ -1,6 +1,9 @@
1
1
  import { Command } from "commander";
2
+ import { type AgentCli, type Schedule } from "../../background/schedules.js";
3
+ import { type MailWaiting } from "../../mail/marks.js";
2
4
  import { type AppIdentity } from "../../ports/app.js";
3
5
  import { type AnswerOutput } from "../envelope.js";
6
+ import type { DeclaredSetting } from "../setting-defaults.js";
4
7
  export interface ScheduleHost {
5
8
  app: AppIdentity;
6
9
  context(command: Command, resolving?: {
@@ -10,14 +13,30 @@ export interface ScheduleHost {
10
13
  profile: string;
11
14
  };
12
15
  /**
13
- * Whether mail is stored past the holder's mark (`holderHasMail`). Given, `schedule agent` takes `--on-new-mail`:
14
- * the agent then runs only after a sync stored mail it has not processed.
16
+ * What mail waits past the holder's mark (`mailWaiting`). Given, `schedule agent` takes `--on-new-mail`: the agent
17
+ * then runs only once enough of it waits, or the oldest has waited long enough.
15
18
  */
16
- newMail?: (command: Command, holder: string) => Promise<boolean>;
19
+ newMail?: (command: Command, holder: string) => Promise<MailWaiting>;
20
+ /** The config file's limits for agent schedules; a schedule's own flags win, `SCHEDULE_DEFAULTS` fill the rest. */
21
+ limits?: (command: Command) => ScheduleLimits;
22
+ /**
23
+ * Other CLIs the scheduled agent may run besides this one, each under its own `<PREFIX>_READ_ONLY`: memo's mail
24
+ * triage runs `mail`. Claude may run them; codex may write their state, config and cache folders.
25
+ */
26
+ agentRuns?: readonly AgentCli[];
17
27
  }
28
+ export type ScheduleLimits = Partial<Pick<Schedule, "mailWaitMinutes" | "mailBatch" | "maxCostPerDay">>;
29
+ /** The agent-schedule limits a host with `newMail` writes into its config file's top level (WC-CMD-30). */
30
+ export declare const SCHEDULE_SETTINGS: readonly DeclaredSetting[];
31
+ /** `SCHEDULE_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
32
+ export declare const scheduleLimitsOf: (config: {
33
+ schedule_mail_wait?: unknown;
34
+ schedule_mail_batch?: unknown;
35
+ schedule_max_cost_per_day?: unknown;
36
+ }) => ScheduleLimits;
18
37
  /**
19
38
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
20
39
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
21
40
  * the rest, so what runs and with which variables is the same on all three.
22
41
  */
23
- export declare const scheduleCommandFor: ({ app, context: contextOf, newMail }: ScheduleHost) => Command;
42
+ export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, limits: configured, agentRuns, }: ScheduleHost) => Command;