@wirecat/cli-core 0.566.0 → 0.567.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,7 +5,37 @@ 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
8
+ ## 0.567.0 — 05.11.2026
9
+
10
+ ### Added
11
+
12
+ - **`<tool> bot list` runs without a profile word**, as `account list` does; `<tool> <profile> bot list` still
13
+ works.
14
+ - **A scheduled agent may run other CLIs the host declares** (`ScheduleHost.agentRuns`, type `AgentCli`): memo's
15
+ mail triage runs `mail`. Claude gets `Bash(<cli>:*)` for each; codex may write each one's state, config and cache
16
+ folders; every run sets `<PREFIX>_READ_ONLY=1` and `<PREFIX>_SCHEDULED` for each, whatever the schedule file says.
17
+ `runSchedule` takes the same list as `agentRuns`.
18
+ - **`<PREFIX>_READ_ONLY` lets through one authority change, `rules add`, when the command is marked
19
+ `narrowsUnderLock`** (`LOCKED_AUTHORITY_COMMANDS`, `admittedUnderLock`). The command narrows itself under the lock;
20
+ every other command marked `grantsAuthority` is still refused, and MCP still needs `mcp.authority`.
21
+
22
+ ### Fixed
23
+
24
+ - **`account list` no longer lists a bot as an account that is not signed in.** A name that only the bot side
25
+ uses (the file's `bot` section, a bot's own files) and that has no personal section, recorded login or session is
26
+ left out; `bot list` has it. A profile that is an account and a bot too says `bot: true`, a new optional field of
27
+ `account_profile`. `mcp serve --profiles all` serves the same account profiles. `Settings` gains the optional
28
+ `accountProfiles` and `botProfiles`.
29
+ - **`config set` keeps a long id as the digits typed.** `config set --bot phone_number_id 100000000000001` was
30
+ parsed as a number and refused by a setting that takes text; when the parsed value is refused and the text as
31
+ typed passes, the text is kept, never through a JS number.
32
+
33
+ ### Changed — may break callers
34
+
35
+ - **A write on a stale task revision fails with `conflict`, exit 15, not `configuration_error`, exit 3.** The reason
36
+ stays `task.stale` with the task's current `revision` in `details`; only the code and the exit number change
37
+ (cli-base's new `conflict`). A caller that branched on `configuration_error` for it should branch on the reason or
38
+ the new code. `vocabulary.stale`, `tag-review.stale` and `meeting-project.stale` keep `configuration_error` for now.
9
39
 
10
40
  ## 0.566.0 — 05.11.2026
11
41
 
@@ -20,6 +20,8 @@ export interface Schedule {
20
20
  /** A holder whose mark gates the run: it starts only when mail is stored past that mark. */
21
21
  onNewMail?: string;
22
22
  }
23
+ /** Another CLI a scheduled agent may run by name, as the host declares it: `memo`'s agent runs `mail`. */
24
+ export type AgentCli = Pick<AppIdentity, "command" | "appName" | "envPrefix">;
23
25
  export interface LastRun {
24
26
  at: string;
25
27
  exitCode: number;
@@ -113,11 +115,13 @@ export declare const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
113
115
  * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
114
116
  * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
115
117
  */
116
- export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, }?: {
118
+ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, agentRuns, }?: {
117
119
  now?: () => Date;
118
120
  timer?: Timer;
119
121
  signal?: AbortSignal;
120
122
  killAfterMs?: number;
121
123
  untilUnlocked?: boolean;
122
124
  wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
125
+ /** The other CLIs an agent may run; each runs under its own read-only lock and knows it runs from a schedule. */
126
+ agentRuns?: readonly AgentCli[];
123
127
  }) => Promise<LastRun>;
@@ -358,7 +358,7 @@ const pause = (ms, signal) => new Promise((resolve) => {
358
358
  * keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
359
359
  * 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
360
360
  */
361
- export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, } = {}) => {
361
+ export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, agentRuns = [], } = {}) => {
362
362
  const schedule = readSchedule(file);
363
363
  const folder = join(file, "..");
364
364
  const runs = join(folder, "runs");
@@ -366,12 +366,12 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
366
366
  const agent = schedule.kind === "agent";
367
367
  const retries = untilUnlocked && !("every" in schedule.when);
368
368
  const firstAt = now().getTime();
369
- let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
369
+ let last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns });
370
370
  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
371
  await wait(delay, signal);
372
372
  if (signal?.aborted)
373
373
  break;
374
- last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs });
374
+ last = await attemptSchedule(app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns });
375
375
  }
376
376
  const { exitCode, output } = last;
377
377
  const errors = agent ? output.replace(/\.md$/, ".log") : output;
@@ -399,7 +399,7 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
399
399
  return last;
400
400
  };
401
401
  /** 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 }) => {
402
+ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns, }) => {
403
403
  const runs = join(folder, "runs");
404
404
  const stamp = now()
405
405
  .toISOString()
@@ -417,6 +417,12 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
417
417
  [`${app.envPrefix}_SCHEDULED`]: schedule.name,
418
418
  // A run started under the lock stays under it, whatever the schedule file says.
419
419
  ...(agent || readOnlyLockOf(app, env) ? { [`${app.envPrefix}_READ_ONLY`]: "1" } : {}),
420
+ ...(agent
421
+ ? Object.fromEntries(agentRuns.flatMap((other) => [
422
+ [`${other.envPrefix}_READ_ONLY`, "1"],
423
+ [`${other.envPrefix}_SCHEDULED`, schedule.name],
424
+ ]))
425
+ : {}),
420
426
  };
421
427
  // The child's output is data, and an agent's is another program's: it is kept as it came. These lines say
422
428
  // only when it ran and how it ended.
@@ -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,7 +71,7 @@ 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";
package/dist/cli/index.js CHANGED
@@ -62,7 +62,7 @@ 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";
@@ -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,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ import { type AgentCli } from "../../background/schedules.js";
2
3
  import { type AppIdentity } from "../../ports/app.js";
3
4
  import { type AnswerOutput } from "../envelope.js";
4
5
  export interface ScheduleHost {
@@ -14,10 +15,15 @@ export interface ScheduleHost {
14
15
  * the agent then runs only after a sync stored mail it has not processed.
15
16
  */
16
17
  newMail?: (command: Command, holder: string) => Promise<boolean>;
18
+ /**
19
+ * Other CLIs the scheduled agent may run besides this one, each under its own `<PREFIX>_READ_ONLY`: memo's mail
20
+ * triage runs `mail`. Claude may run them; codex may write their state, config and cache folders.
21
+ */
22
+ agentRuns?: readonly AgentCli[];
17
23
  }
18
24
  /**
19
25
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
20
26
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
21
27
  * the rest, so what runs and with which variables is the same on all three.
22
28
  */
23
- export declare const scheduleCommandFor: ({ app, context: contextOf, newMail }: ScheduleHost) => Command;
29
+ export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, agentRuns }: ScheduleHost) => Command;
@@ -81,7 +81,8 @@ const promptExample = (app) => app.messenger === false
81
81
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
82
82
  * the rest, so what runs and with which variables is the same on all three.
83
83
  */
84
- export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
84
+ export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentRuns = [] }) => {
85
+ const agentClis = [app, ...agentRuns];
85
86
  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
87
  const prepare = (self, resolving = {}) => {
87
88
  const context = contextOf(self, resolving);
@@ -154,7 +155,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
154
155
  `${app.command} personal schedule agent triage codex "list what needs an answer today" --every 2h --max-cost 1`,
155
156
  ],
156
157
  }))
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`)
158
+ .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
159
  .argument("<name>", "lowercase letters, digits and dashes")
159
160
  .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
160
161
  .argument("<prompt>", `what to do, e.g. "${promptExample(app)}"`)
@@ -166,7 +167,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
166
167
  const program = findProgram(harness, context.env, system.platform);
167
168
  if (!program)
168
169
  throw new CliError("not_found", `${harness} is not on PATH`);
169
- const paths = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env });
170
+ const folders = agentClis.flatMap((one) => {
171
+ const paths = resolvePaths({ appName: one.appName, prefix: one.envPrefix, env: context.env });
172
+ return [paths.state, paths.config, paths.cache];
173
+ });
170
174
  const argv = harness === "claude"
171
175
  ? [
172
176
  program,
@@ -176,7 +180,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
176
180
  "--permission-mode",
177
181
  "dontAsk",
178
182
  "--allowedTools",
179
- `Bash(${app.command}:*)`,
183
+ ...agentClis.map((one) => `Bash(${one.command}:*)`),
180
184
  "--no-session-persistence",
181
185
  ...(options.maxCost ? ["--max-budget-usd", String(options.maxCost)] : []),
182
186
  ]
@@ -187,13 +191,10 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
187
191
  "--ephemeral",
188
192
  "--sandbox",
189
193
  "workspace-write",
190
- // The CLI reaches the messenger and writes its store and state, and nothing else.
194
+ // The CLIs reach their provider and write the store and their own state, and nothing else.
191
195
  "-c",
192
196
  "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
- ]),
197
+ ...[dirname(storePath(context.env)), ...folders].flatMap((dir) => ["--add-dir", dir]),
197
198
  prompt,
198
199
  ];
199
200
  await save({
@@ -263,6 +264,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
263
264
  return say({ name, skipped: true, holder }, [`${name}: skipped — no mail stored past ${holder}'s mark`]);
264
265
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
265
266
  timer: timer(),
267
+ agentRuns,
266
268
  ...(signal ? { signal } : {}),
267
269
  }));
268
270
  say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
@@ -297,6 +299,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail }) => {
297
299
  return;
298
300
  const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
299
301
  timer: timer(),
302
+ agentRuns,
300
303
  untilUnlocked: true,
301
304
  ...(signal ? { signal } : {}),
302
305
  }));
@@ -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";
@@ -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>]>;
@@ -235,7 +235,7 @@ const OTHERS = [
235
235
  validation("task.category-unknown", "the category is not in the task category list"),
236
236
  validation("task.claimed", "someone else holds the task"),
237
237
  validation("task.closed", "the task is already closed"),
238
- ["task.stale", "configuration_error", "the task changed since it was read; read it again"],
238
+ ["task.stale", "conflict", "the task changed since it was read; read it again"],
239
239
  validation("task.column", "the task beside which it moves is in another column than the status asked for"),
240
240
  validation("task.dependency-cycle", "the task would depend on itself"),
241
241
  validation("task.is-subtask", "a subtask moves to another project alone only when it is detached from its parent in the same write"),
@@ -68,7 +68,7 @@ const timesAsIso = (changes) => Object.fromEntries(Object.entries(changes).map((
68
68
  export const checkRevision = (row, expected) => {
69
69
  if (expected === undefined || Number(row.revision) === expected)
70
70
  return;
71
- throw new CliError("configuration_error", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
71
+ throw new CliError("conflict", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
72
72
  };
73
73
  export const workTaskOperationsOver = (context) => {
74
74
  const { database, now } = context;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.566.0",
3
+ "version": "0.567.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",