@wirecat/cli-core 0.567.0 → 0.569.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,64 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.569.0 — 05.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **A claude agent schedule runs on Sonnet and starts lean.** `schedule agent` takes `--model <model>` (an alias such
15
+ as `sonnet` or `opus`, a model id, or `""` for the harness's own default). Without it, a claude schedule runs on
16
+ `sonnet` and a codex schedule on codex's own default; a host's config file may change either
17
+ (`schedule_model_claude`, `schedule_model_codex` in `SCHEDULE_SETTINGS`): flag, then config file, then default.
18
+ A new claude schedule also starts with `--tools Bash --strict-mcp-config --disable-slash-commands
19
+ --setting-sources ""`: none of the owner's MCP servers, skills, settings or CLAUDE.md, while `--permission-mode
20
+ dontAsk` and `--allowedTools` still hold. A "say ok" run measured $0.152 and 37,925 input tokens before, $0.019 and
21
+ 4,740 after. `schedule_lean_start: false` keeps the owner's setup. Both are read when the schedule is added; an
22
+ existing schedule keeps its argv until it is added again. `ScheduleLimits` gains `models` and `leanStart`.
23
+
24
+ ## 0.568.0 — 05.11.2026
25
+
26
+ ### Changed — may break callers
27
+
28
+ - **`holderHasMail` is gone: `mailWaiting` checks and `advancePastSkipped` moves.** `holderHasMail` read like a
29
+ check but moved the holder's mark on every schedule tick. `mailWaiting(store, holder)` only reads: how many emails
30
+ past the holder's marks would reach it and when the oldest was stored, or `unmarked`. `advancePastSkipped(store,
31
+ holder)` moves the mark over the mail at its head the holder would only skip, and leaves it for the next check when
32
+ a sync holds the store. `ScheduleHost.newMail` now returns `MailWaiting`. A host calls `advancePastSkipped`, then
33
+ `mailWaiting`.
34
+ - **A gated agent schedule waits for a batch of mail.** On its timer, `schedule agent --on-new-mail <holder>` starts
35
+ the agent once 10 emails wait for it or the oldest has waited 30 minutes; `--mail-wait <duration>` and
36
+ `--mail-batch <n>` change that per schedule, and a host's `ScheduleHost.limits` gives its config file's values
37
+ (`SCHEDULE_SETTINGS`, read with `scheduleLimitsOf`): flag, then config file, then default. `schedule run` by hand
38
+ still starts it for any mail. Its skipped answer names `why` instead of `holder`.
39
+ - **An agent schedule stops for the day at a cost cap.** A claude run is asked for its JSON answer, so its cost
40
+ (`total_cost_usd`) is kept in `last.json` (`costUsd`) and summed per local day in `spend.json`; the report kept is
41
+ 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
42
+ none), no run starts until the next day; `last.json` says why under `skipped`, and `schedule list` shows the day's
43
+ spend and the skip.
44
+ - **`messages acknowledge` moves only a holder the caller may move.** A run that `schedule agent --on-new-mail` started
45
+ gets `<PREFIX>_MAIL_HOLDER` and moves that holder's mark only; any other agent, or a run under the read-only lock,
46
+ is refused with `mail.holder-not-yours`; the owner moves any. A cursor past the newest stored email, or read in the
47
+ future, is refused with `mail.cursor-ahead`. A scheduled triage agent needs `--on-new-mail`.
48
+ - **`review --holder` leaves out suspicious senders, and now keeps them on the mark.** Their
49
+ emails stay on the holder's mark and come in a later review once the sender's score is cleared, instead of being
50
+ passed for good. This was listed under Added in the release that brought it; it changes what an existing caller
51
+ reads.
52
+ - **A mail findings citation of an email the store does not hold is `not_found`** with reason
53
+ `mail-findings.not-stored`, no longer `mail-findings.stale`, which told the agent to read the mailbox again. An email
54
+ deleted after the batch was staged still makes accepting it stale.
55
+ - **`parseMailFindingsJson`, `parseMeetingFindingsJson` and `parseEvidenceFindingsJson` live in
56
+ `src/cli/findings-input.ts`;** the exports from `@wirecat/cli-core/cli` are unchanged. `findingText` and
57
+ `findingFields` are the checks themselves, not wrappers.
58
+
59
+ ### Fixed
60
+
61
+ - **An accepted `new_contact` mail finding names the person.** Sync already names a sender's person from the From
62
+ display name, so accepting set nothing while the receipt said accepted; the owner's chosen name now replaces it.
63
+ - **`mail review` offers at most two next steps.** It gave one `conversations show` step per cut thread, up to 50;
64
+ it now gives one, and names the other cut threads under `missing`.
65
+
8
66
  ## 0.567.0 — 05.11.2026
9
67
 
10
68
  ### Added
@@ -19,7 +19,27 @@ 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;
23
43
  /** Another CLI a scheduled agent may run by name, as the host declares it: `memo`'s agent runs `mail`. */
24
44
  export type AgentCli = Pick<AppIdentity, "command" | "appName" | "envPrefix">;
25
45
  export interface LastRun {
@@ -37,6 +57,13 @@ export interface LastRun {
37
57
  failingSince?: string;
38
58
  /** Failed runs in a row; absent once a run succeeds. */
39
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
+ };
40
67
  }
41
68
  /**
42
69
  * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
@@ -60,7 +87,10 @@ export declare const recordRun: (path: string, run: {
60
87
  traceId?: string;
61
88
  reason?: string;
62
89
  errorCode?: string;
90
+ costUsd?: number;
63
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;
64
94
  /**
65
95
  * One stderr line per failing background job, or nothing: `tg: scheduled digest failing since … (3 runs):
66
96
  * <reason> — tg schedule list`. A status the owner has to ask for is how a broken timer goes unnoticed.
@@ -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");
@@ -408,13 +460,14 @@ 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" } : {}),
420
473
  ...(agent
@@ -430,6 +483,7 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
430
483
  const facts = { schedule: schedule.name, kind: schedule.kind };
431
484
  const began = now().getTime();
432
485
  log.info({ event: "schedule.start", ...facts });
486
+ let costUsd;
433
487
  const exitCode = await new Promise((resolve) => {
434
488
  const child = spawn(program, args, { env: childEnv, cwd: folder, stdio: ["ignore", "pipe", "pipe"] });
435
489
  const out = [];
@@ -454,24 +508,30 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
454
508
  signal?.removeEventListener("abort", stop);
455
509
  if (kill)
456
510
  clearTimeout(kill);
457
- 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 });
458
514
  if (agent)
459
515
  writeFileSync(errors, Buffer.concat(err), { mode: 0o600 });
460
516
  resolve(code ?? (killedBy === "SIGKILL" ? 137 : killedBy === "SIGTERM" ? 143 : 1));
461
517
  });
462
518
  });
463
519
  const ended = now();
520
+ if (costUsd !== undefined)
521
+ addSpend(folder, costUsd, ended);
464
522
  log[exitCode === 0 ? "info" : "error"]({
465
523
  event: "schedule.exit",
466
524
  ...facts,
467
525
  exitCode,
468
526
  durationMs: ended.getTime() - began,
527
+ ...(costUsd === undefined ? {} : { costUsd }),
469
528
  });
470
529
  return recordRun(join(folder, "last.json"), {
471
530
  at: ended.toISOString(),
472
531
  exitCode,
473
532
  output,
474
533
  traceId: trace.traceId,
534
+ ...(costUsd === undefined ? {} : { costUsd }),
475
535
  ...(exitCode === 0 ? {} : failureOf(readFileSync(errors, "utf8"), exitCode)),
476
536
  });
477
537
  };
@@ -75,6 +75,7 @@ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthori
75
75
  export { type CliEntry, type EntryContext, type EntryIo, openBrowser, startCli } from "./entry.js";
76
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";
77
77
  export { withAbort } from "./execution.js";
78
+ export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./findings-input.js";
78
79
  export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, setupAtStart, systemLanguage, } from "./first-run.js";
79
80
  export { checkGuide, checkStep, type GuideOptions, type GuideReference, type GuideStep, loadScenarios, MEMO_SCENARIOS, type Scenario, shellProblems, } from "./guide.js";
80
81
  export { type CommandHelp, commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, type NextChoice, nextUsually, } from "./help/command-help.js";
@@ -87,7 +88,6 @@ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists,
87
88
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
88
89
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
89
90
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
90
- export { parseEvidenceFindingsJson, parseMailFindingsJson, parseMeetingFindingsJson, } from "./meeting-findings-input.js";
91
91
  export { meetingPreparationAnswer } from "./meeting-preparation-answer.js";
92
92
  export { type MeetingPreparationReader, meetingPreparationInput, readMeetingPreparation, } from "./meeting-preparation-read.js";
93
93
  export { readMeetingProjectCandidates } from "./meeting-project-candidates-read.js";
@@ -120,7 +120,7 @@ export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEv
120
120
  export { type ProjectStatusHints, type ProjectStatusItem, projectStatusAnswer, projectStatusRow, projectStatusSchema, projectStatusText, } from "./project-status.js";
121
121
  export { STATUS_ROWS, STATUS_SIGNAL_ROWS } from "./project-status-rows.js";
122
122
  export { fieldsOf, projectFields } from "./result-fields.js";
123
- export type { ScheduleHost } from "./schedule/schedule-command.js";
123
+ export { SCHEDULE_SETTINGS, type ScheduleHost, type ScheduleLimits, scheduleLimitsOf, } from "./schedule/schedule-command.js";
124
124
  export { type DeclaredSetting, type SettingRow, settingRows, withSettingDefaults } from "./setting-defaults.js";
125
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";
126
126
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
package/dist/cli/index.js CHANGED
@@ -66,6 +66,7 @@ export { admittedUnderLock, changesAuthority, changesAuthorityAt, changesAuthori
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";
@@ -1,7 +1,11 @@
1
1
  import { Command } from "commander";
2
- import { type AgentCli } from "../../background/schedules.js";
2
+ import { type AgentCli, type Schedule } from "../../background/schedules.js";
3
+ import { type MailWaiting } from "../../mail/marks.js";
3
4
  import { type AppIdentity } from "../../ports/app.js";
4
5
  import { type AnswerOutput } from "../envelope.js";
6
+ import type { DeclaredSetting } from "../setting-defaults.js";
7
+ declare const HARNESSES: readonly ["claude", "codex"];
8
+ type Harness = (typeof HARNESSES)[number];
5
9
  export interface ScheduleHost {
6
10
  app: AppIdentity;
7
11
  context(command: Command, resolving?: {
@@ -11,19 +15,37 @@ export interface ScheduleHost {
11
15
  profile: string;
12
16
  };
13
17
  /**
14
- * Whether mail is stored past the holder's mark (`holderHasMail`). Given, `schedule agent` takes `--on-new-mail`:
15
- * the agent then runs only after a sync stored mail it has not processed.
18
+ * What mail waits past the holder's mark (`mailWaiting`). Given, `schedule agent` takes `--on-new-mail`: the agent
19
+ * then runs only once enough of it waits, or the oldest has waited long enough.
16
20
  */
17
- newMail?: (command: Command, holder: string) => Promise<boolean>;
21
+ newMail?: (command: Command, holder: string) => Promise<MailWaiting>;
22
+ /** The config file's settings for agent schedules; a schedule's own flags win, the defaults fill the rest. */
23
+ limits?: (command: Command) => ScheduleLimits;
18
24
  /**
19
25
  * Other CLIs the scheduled agent may run besides this one, each under its own `<PREFIX>_READ_ONLY`: memo's mail
20
26
  * triage runs `mail`. Claude may run them; codex may write their state, config and cache folders.
21
27
  */
22
28
  agentRuns?: readonly AgentCli[];
23
29
  }
30
+ export type ScheduleLimits = Partial<Pick<Schedule, "mailWaitMinutes" | "mailBatch" | "maxCostPerDay"> & {
31
+ models: Partial<Record<Harness, string>>;
32
+ leanStart: boolean;
33
+ }>;
34
+ /** The agent-schedule settings a host with `newMail` writes into its config file's top level (WC-CMD-30). */
35
+ export declare const SCHEDULE_SETTINGS: readonly DeclaredSetting[];
36
+ /** `SCHEDULE_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
37
+ export declare const scheduleLimitsOf: (config: {
38
+ schedule_mail_wait?: unknown;
39
+ schedule_mail_batch?: unknown;
40
+ schedule_max_cost_per_day?: unknown;
41
+ schedule_model_claude?: unknown;
42
+ schedule_model_codex?: unknown;
43
+ schedule_lean_start?: unknown;
44
+ }) => ScheduleLimits;
24
45
  /**
25
46
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
26
47
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
27
48
  * the rest, so what runs and with which variables is the same on all three.
28
49
  */
29
- export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, agentRuns }: ScheduleHost) => Command;
50
+ export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, limits: configured, agentRuns, }: ScheduleHost) => Command;
51
+ export {};
@@ -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";
@@ -23,6 +23,15 @@ const AGENT_MINIMUM_MINUTES = 60;
23
23
  const GATED_AGENT_MINIMUM_MINUTES = 5;
24
24
  const COMMAND_MINIMUM_MINUTES = 1;
25
25
  const HARNESSES = ["claude", "codex"];
26
+ /** A mid-tier model by default; codex has no alias of that tier, so it takes its own default. Empty: the harness's own. */
27
+ const AGENT_MODELS = { claude: "sonnet", codex: "" };
28
+ /** An alias or a model id; a leading dash would read as the harness's next flag. */
29
+ const isModel = (model) => /^([A-Za-z0-9][\w.:/@[\]-]*)?$/.test(model);
30
+ /**
31
+ * Claude starts without the owner's MCP servers, skills, settings and CLAUDE.md: a run reads its instructions
32
+ * through the CLI, and the rest was most of its starting context. Auth, `--permission-mode` and `--allowedTools` hold.
33
+ */
34
+ const LEAN_CLAUDE = ["--tools", "Bash", "--strict-mcp-config", "--disable-slash-commands", "--setting-sources", ""];
26
35
  const whenOf = ({ every, at, atTime }, minimum) => {
27
36
  if ([every ?? at, atTime].filter(Boolean).length !== 1) {
28
37
  throw new CliError("validation_error", "say when: --every <30m|2h|1d>, --at HH:MM (daily), or --at-time <30m|2d|ISO time>");
@@ -76,12 +85,65 @@ const withWhen = (command) => command
76
85
  const promptExample = (app) => app.messenger === false
77
86
  ? `summarize what came in during the last day: ${app.command} personal search messages 'date>1d' --newest --limit 20`
78
87
  : `run the daily follow-up review: ${app.command} skill show follow-ups`;
88
+ /** The agent-schedule settings a host with `newMail` writes into its config file's top level (WC-CMD-30). */
89
+ export const SCHEDULE_SETTINGS = [
90
+ {
91
+ key: "schedule_mail_wait",
92
+ default: `${SCHEDULE_DEFAULTS.mailWaitMinutes}m`,
93
+ description: "an agent gated on new mail starts once the oldest waiting email has waited this long",
94
+ },
95
+ {
96
+ key: "schedule_mail_batch",
97
+ default: SCHEDULE_DEFAULTS.mailBatch,
98
+ description: "or once this many emails wait for it",
99
+ },
100
+ {
101
+ key: "schedule_max_cost_per_day",
102
+ default: SCHEDULE_DEFAULTS.maxCostPerDay,
103
+ description: "US dollars an agent schedule's runs may cost in a day before it waits for the next; 0 for no cap",
104
+ },
105
+ {
106
+ key: "schedule_model_claude",
107
+ default: AGENT_MODELS.claude,
108
+ description: "the model a new claude agent schedule runs on, unless --model names one; empty for claude's own default",
109
+ },
110
+ {
111
+ key: "schedule_model_codex",
112
+ default: AGENT_MODELS.codex,
113
+ description: "the model a new codex agent schedule runs on, unless --model names one; empty for codex's own default",
114
+ },
115
+ {
116
+ key: "schedule_lean_start",
117
+ default: true,
118
+ description: "a new claude agent schedule starts without your MCP servers, skills, settings and CLAUDE.md; false loads them",
119
+ },
120
+ ];
121
+ /** `SCHEDULE_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
122
+ export const scheduleLimitsOf = (config) => {
123
+ const { schedule_mail_wait: wait, schedule_mail_batch: batch, schedule_max_cost_per_day: cap } = config;
124
+ const models = Object.fromEntries(HARNESSES.flatMap((harness) => {
125
+ const model = config[`schedule_model_${harness}`];
126
+ return typeof model === "string" && isModel(model.trim()) ? [[harness, model.trim()]] : [];
127
+ }));
128
+ return {
129
+ ...(Object.keys(models).length ? { models } : {}),
130
+ ...(typeof config.schedule_lean_start === "boolean" ? { leanStart: config.schedule_lean_start } : {}),
131
+ ...(typeof wait === "string"
132
+ ? { mailWaitMinutes: Math.ceil(parseDuration(wait, "schedule_mail_wait") / 60_000) }
133
+ : {}),
134
+ ...(typeof batch === "number" && Number.isSafeInteger(batch) && batch >= 1 ? { mailBatch: batch } : {}),
135
+ ...(typeof cap === "number" && Number.isFinite(cap) && cap >= 0 ? { maxCostPerDay: cap } : {}),
136
+ };
137
+ };
138
+ const given = (limits) => Object.fromEntries(Object.entries(limits).filter(([, value]) => value !== undefined));
139
+ const shown = (argv) => argv.map((arg) => (arg === "" ? '""' : arg)).join(" ");
140
+ const usd = (value) => `$${value.toFixed(2)}`;
79
141
  /**
80
142
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
81
143
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
82
144
  * the rest, so what runs and with which variables is the same on all three.
83
145
  */
84
- export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns = [] }) => {
146
+ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: configured, agentRuns = [], }) => {
85
147
  const agentClis = [app, ...agentRuns];
86
148
  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`);
87
149
  const prepare = (self, resolving = {}) => {
@@ -112,18 +174,50 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
112
174
  await timer().install(schedule, [...system.entry, "schedule", "exec", file]);
113
175
  say(scheduleAnswer(schedule, { file, timer: timer().where(schedule.name), replaced }), [
114
176
  `${replaced ? "Replaced" : "Added"} ${schedule.name}: ${describeWhen(schedule.when)}.`,
115
- `It runs: ${schedule.kind === "agent" ? schedule.argv.join(" ") : `${app.command} ${schedule.argv.join(" ")}`}`,
177
+ `It runs: ${schedule.kind === "agent" ? shown(schedule.argv) : `${app.command} ${shown(schedule.argv)}`}`,
116
178
  `Output: ${join(dirname(file), "runs")} · now: ${lineOf(self, `schedule run ${schedule.name}`)} · stop: ${lineOf(self, `schedule remove ${schedule.name}`)}`,
117
179
  ]);
118
180
  };
119
181
  return { context, system, state, say, timer, environment, save };
120
182
  };
121
- /** A schedule gated on new mail waits while nothing is stored past its holder's mark. */
122
- const waits = async (self, file) => {
123
- const holder = existsSync(file) ? readSchedule(file).onNewMail : undefined;
124
- if (holder === undefined || !newMail || (await newMail(self, holder)))
183
+ /**
184
+ * Why a run does not start now, or nothing: an agent whose runs reached the day's cost cap waits for tomorrow, and
185
+ * one gated on mail waits for mail past its holder's mark — on its timer, until enough of it waits.
186
+ */
187
+ const waits = async (self, file, { timed }) => {
188
+ if (!existsSync(file))
189
+ return undefined;
190
+ const schedule = readSchedule(file);
191
+ const { mailWaitMinutes, mailBatch, maxCostPerDay } = {
192
+ ...SCHEDULE_DEFAULTS,
193
+ ...given(configured?.(self) ?? {}),
194
+ ...given({
195
+ mailWaitMinutes: schedule.mailWaitMinutes,
196
+ mailBatch: schedule.mailBatch,
197
+ maxCostPerDay: schedule.maxCostPerDay,
198
+ }),
199
+ };
200
+ if (schedule.kind === "agent" && maxCostPerDay > 0) {
201
+ const spent = spentToday(file);
202
+ if (spent >= maxCostPerDay) {
203
+ const why = `today's runs cost ${usd(spent)}, the ${usd(maxCostPerDay)} a day allowed; it runs again tomorrow`;
204
+ recordSkip(file, why);
205
+ return why;
206
+ }
207
+ }
208
+ const holder = schedule.onNewMail;
209
+ if (holder === undefined || !newMail)
210
+ return undefined;
211
+ const waiting = await newMail(self, holder);
212
+ if (waiting.unmarked)
213
+ return undefined;
214
+ if (waiting.emails === 0)
215
+ return `no mail stored past ${holder}'s mark`;
216
+ const waited = waiting.oldestStoredAt === null ? 0 : Date.now() - waiting.oldestStoredAt;
217
+ if (!timed || waiting.emails >= mailBatch || waited >= mailWaitMinutes * 60_000)
125
218
  return undefined;
126
- return holder;
219
+ return (`${waiting.emails} email(s) wait past ${holder}'s mark; it starts at ${mailBatch}, ` +
220
+ `or once the oldest has waited ${mailWaitMinutes}m`);
127
221
  };
128
222
  withWhen(grantsAuthority(annotate(command.command("command"), {
129
223
  literal_after_dashes: true,
@@ -153,17 +247,33 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
153
247
  examples: [
154
248
  `${app.command} personal schedule agent review claude "review what came in overnight and draft replies" --at 08:30`,
155
249
  `${app.command} personal schedule agent triage codex "list what needs an answer today" --every 2h --max-cost 1`,
250
+ `${app.command} personal schedule agent weekly claude "review the week's open threads" --at 09:00 --model opus`,
156
251
  ],
157
252
  }))
158
253
  .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`)
159
254
  .argument("<name>", "lowercase letters, digits and dashes")
160
255
  .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
161
256
  .argument("<prompt>", `what to do, e.g. "${promptExample(app)}"`)
162
- .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) {
257
+ .option("--model <model>", `the model it runs on: an alias such as sonnet, or a model id; "" for the harness's default (default sonnet for claude, codex's own for codex, or the config file's)`)
258
+ .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))
259
+ .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) {
163
260
  const { context, system, environment, save } = prepare(this);
164
261
  const options = this.opts();
165
262
  if (options.onNewMail !== undefined && !HOLDER.test(options.onNewMail))
166
263
  throw new CliError("validation_error", `--on-new-mail takes a holder: lower-case letters, digits and dashes`);
264
+ if ((options.mailWait !== undefined || options.mailBatch !== undefined) && options.onNewMail === undefined)
265
+ throw new CliError("validation_error", "--mail-wait and --mail-batch shape a run gated by --on-new-mail");
266
+ if (options.maxCostPerDay !== undefined && !(Number.isFinite(options.maxCostPerDay) && options.maxCostPerDay >= 0))
267
+ throw new CliError("validation_error", "--max-cost-per-day takes US dollars, 0 or more");
268
+ const mailBatch = options.mailBatch === undefined ? undefined : Number(options.mailBatch);
269
+ if (mailBatch !== undefined && !(Number.isSafeInteger(mailBatch) && mailBatch >= 1))
270
+ throw new CliError("validation_error", "--mail-batch takes a count of emails, 1 or more");
271
+ const mailWaitMinutes = options.mailWait === undefined ? undefined : Math.ceil(parseDuration(options.mailWait, "--mail-wait") / 60_000);
272
+ if (options.model !== undefined && !isModel(options.model))
273
+ throw new CliError("validation_error", `--model takes an alias such as sonnet or a model id, not "${options.model}"`);
274
+ const settings = configured?.(this) ?? {};
275
+ const model = options.model ?? settings.models?.[harness] ?? AGENT_MODELS[harness];
276
+ const modelFlag = model ? ["--model", model] : [];
167
277
  const program = findProgram(harness, context.env, system.platform);
168
278
  if (!program)
169
279
  throw new CliError("not_found", `${harness} is not on PATH`);
@@ -182,6 +292,8 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
182
292
  "--allowedTools",
183
293
  ...agentClis.map((one) => `Bash(${one.command}:*)`),
184
294
  "--no-session-persistence",
295
+ ...modelFlag,
296
+ ...((settings.leanStart ?? true) ? LEAN_CLAUDE : []),
185
297
  ...(options.maxCost ? ["--max-budget-usd", String(options.maxCost)] : []),
186
298
  ]
187
299
  : [
@@ -194,6 +306,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
194
306
  // The CLIs reach their provider and write the store and their own state, and nothing else.
195
307
  "-c",
196
308
  "sandbox_workspace_write.network_access=true",
309
+ ...modelFlag,
197
310
  ...[dirname(storePath(context.env)), ...folders].flatMap((dir) => ["--add-dir", dir]),
198
311
  prompt,
199
312
  ];
@@ -205,12 +318,17 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
205
318
  environment: environment(),
206
319
  createdAt: new Date().toISOString(),
207
320
  ...(options.onNewMail ? { onNewMail: options.onNewMail } : {}),
321
+ ...(mailWaitMinutes === undefined ? {} : { mailWaitMinutes }),
322
+ ...(mailBatch === undefined ? {} : { mailBatch }),
323
+ ...(options.maxCostPerDay === undefined ? {} : { maxCostPerDay: options.maxCostPerDay }),
208
324
  });
209
325
  });
210
326
  if (newMail)
211
327
  command.commands
212
328
  .find((one) => one.name() === "agent")
213
- ?.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`);
329
+ ?.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`)
330
+ .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)`)
331
+ .option("--mail-batch <n>", `with --on-new-mail: or once this many emails wait (default ${SCHEDULE_DEFAULTS.mailBatch}, or the config file's)`);
214
332
  withQuery(annotate(command.command("list"), {
215
333
  effects: LOCAL_READ,
216
334
  examples: [`${app.command} personal schedule list`, `${app.command} personal schedule list kind:agent`],
@@ -224,6 +342,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
224
342
  kind: schedule.kind,
225
343
  when: describeWhen(schedule.when),
226
344
  last: lastRun(scheduleFile(state, schedule.name)) ?? null,
345
+ ...(schedule.kind === "agent" ? { spentTodayUsd: spentToday(scheduleFile(state, schedule.name)) } : {}),
227
346
  }));
228
347
  const answer = listOf("schedule list", {
229
348
  items: rows.map((row) => snakeKeys(row)),
@@ -236,7 +355,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
236
355
  return "";
237
356
  }
238
357
  return rows
239
- .map(({ name, kind, when, last }) => [
358
+ .map(({ name, kind, when, last, spentTodayUsd }) => [
240
359
  name,
241
360
  kind,
242
361
  when,
@@ -247,6 +366,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
247
366
  `${last.failures} in a row${last.reason ? `: ${last.reason}` : ""} — ${last.output}`
248
367
  : "")
249
368
  : "not run yet",
369
+ ...(spentTodayUsd ? [`today ${usd(spentTodayUsd)}`] : []),
370
+ ...(last?.skipped
371
+ ? [`skipped ${last.skipped.at.slice(0, 16).replace("T", " ")}: ${last.skipped.why}`]
372
+ : []),
250
373
  ].join(" "))
251
374
  .join("\n");
252
375
  });
@@ -259,9 +382,9 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
259
382
  .argument("<name>", "the schedule's name, as `schedule list` shows it")
260
383
  .action(async function (name) {
261
384
  const { context, system, state, say, timer } = prepare(this);
262
- const holder = await waits(this, scheduleFile(state, scheduleName(name)));
263
- if (holder !== undefined)
264
- return say({ name, skipped: true, holder }, [`${name}: skipped — no mail stored past ${holder}'s mark`]);
385
+ const why = await waits(this, scheduleFile(state, scheduleName(name)), { timed: false });
386
+ if (why !== undefined)
387
+ return say({ name, skipped: true, why }, [`${name}: skipped — ${why}`]);
265
388
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
266
389
  timer: timer(),
267
390
  agentRuns,
@@ -295,7 +418,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns
295
418
  .action(async function (file) {
296
419
  // A timer starts it with no profile named; the schedule's own environment carries the profile.
297
420
  const { context, system, timer } = prepare(this, { needsProfile: false });
298
- if ((await waits(this, file)) !== undefined)
421
+ if ((await waits(this, file, { timed: true })) !== undefined)
299
422
  return;
300
423
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
301
424
  timer: timer(),
@@ -247,7 +247,7 @@ export const settingsFor = (app, extension = {}) => {
247
247
  const declaredSettings = [...SHARED_SETTINGS, ...(extension.settings ?? [])]
248
248
  .filter(({ key, default: value }) => allSettings.includes(key) && v.safeParse(readSchema, { defaults: { [key]: value } }).success)
249
249
  .map((setting) => ({ ...setting, key: `defaults.${setting.key}` }));
250
- // `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.
251
251
  const withDefaults = (raw, except = []) => withSettingDefaults(isScope(raw) && !Object.hasOwn(raw, "defaults") ? { ...raw, defaults: {} } : raw, declaredSettings.filter(({ key }) => !except.includes(key)));
252
252
  const PROFILE = envName(app, "PROFILE");
253
253
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");