@stratusagent/cli 0.7.0 → 0.9.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/dist/index.js CHANGED
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { spawn } from 'node:child_process';
11
- import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
11
+ import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { createInterface } from 'node:readline';
@@ -17,8 +17,8 @@ import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
17
17
  import { loadPlugins } from '@stratusagent/plugins';
18
18
  import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
19
19
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
20
- import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
21
- import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
20
+ import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
21
+ import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
22
22
  import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
23
23
  export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
24
24
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -70,7 +70,7 @@ const defaultPackageInstaller = async (packages) => {
70
70
  });
71
71
  });
72
72
  };
73
- export const CLI_VERSION = '0.7.0';
73
+ export const CLI_VERSION = '0.9.0';
74
74
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
75
75
  /**
76
76
  * The range every package declares in its `engines` field. Two floors, not
@@ -127,6 +127,8 @@ Usage:
127
127
  stratus skill add stratuslabs/skill-code-review
128
128
  stratus skill add ./my-skills --skill code-review --agent ava
129
129
  stratus skills
130
+ stratus schedules
131
+ stratus schedules cancel <id>
130
132
  stratus doctor
131
133
  stratus service install
132
134
  stratus service status
@@ -173,6 +175,12 @@ Commands:
173
175
  --force replaces an already-installed id
174
176
  skills List installed skills and which agents enable each
175
177
  (also: stratus skill list)
178
+ schedules List every schedule the fleet has set — cadence, prompt,
179
+ pre-authorized destination, next firing — straight from the
180
+ daemon's database (--format json). "stratus schedules
181
+ cancel <id>" stops the next firing and revokes the
182
+ destination that was approved with it
183
+ (also: stratus schedule list / schedule cancel <id>)
176
184
  agent new Create an agent identity (generates a human-ish name + avatar theme)
177
185
  agents List your agents: who they are, where their souls live, what
178
186
  they run on, what they remember (also: stratus agent list).
@@ -485,6 +493,48 @@ export const parseCommand = (argv, env = {}) => {
485
493
  ...(token ? { token } : {}),
486
494
  };
487
495
  }
496
+ if (command === 'schedules' || (command === 'schedule' && rest[0] === 'list') || (command === 'schedule' && rest[0] === 'cancel')) {
497
+ const schedulesRest = command === 'schedules' ? rest : rest.slice(1);
498
+ const action = (command === 'schedule' ? rest[0] : schedulesRest[0]) === 'cancel' ? 'cancel' : 'list';
499
+ const tokens = action === 'cancel' && command === 'schedules' ? schedulesRest.slice(1) : schedulesRest;
500
+ let format = 'text';
501
+ let scheduleId;
502
+ for (let index = 0; index < tokens.length; index += 1) {
503
+ const token = tokens[index];
504
+ if (!token || (action === 'list' && token === 'list' && index === 0)) {
505
+ continue;
506
+ }
507
+ if (token === '--help' || token === '-h') {
508
+ return { command: 'help' };
509
+ }
510
+ if (token === '--format') {
511
+ const value = readOptionValue(tokens, index, '--format');
512
+ if (value !== 'text' && value !== 'json') {
513
+ throw new Error(`Unsupported format: ${value}`);
514
+ }
515
+ format = value;
516
+ index += 1;
517
+ continue;
518
+ }
519
+ if (token.startsWith('--')) {
520
+ throw new Error(`Unknown option: ${token}`);
521
+ }
522
+ if (action === 'cancel' && scheduleId === undefined) {
523
+ scheduleId = token;
524
+ continue;
525
+ }
526
+ throw new Error(`Unexpected argument: ${token}. Try: stratus schedules, stratus schedules cancel <id>`);
527
+ }
528
+ if (action === 'cancel' && !scheduleId) {
529
+ throw new Error('schedules cancel needs the schedule id: stratus schedules cancel <id>.');
530
+ }
531
+ return {
532
+ command: 'schedules',
533
+ action,
534
+ ...(scheduleId ? { scheduleId } : {}),
535
+ format,
536
+ };
537
+ }
488
538
  if (command === 'doctor') {
489
539
  let format = 'text';
490
540
  let configPath;
@@ -895,7 +945,7 @@ export const formatEvent = (event) => {
895
945
  * granted permission leaves no trace whatsoever. Still no tool input: what
896
946
  * was asked is here, what it was asked with is not.
897
947
  */
898
- const eventDetail = (event) => {
948
+ export const eventDetail = (event) => {
899
949
  switch (event.type) {
900
950
  case 'session.updated':
901
951
  return { status: event.status };
@@ -904,8 +954,19 @@ const eventDetail = (event) => {
904
954
  case 'tool.called':
905
955
  case 'tool.denied':
906
956
  return { tool: event.call.toolName };
907
- case 'tool.completed':
908
- return { tool: event.result.toolName, ok: event.result.ok };
957
+ case 'tool.completed': {
958
+ // A memory write or retirement names the entry it touched — the id
959
+ // is a reference, not content, and "when did the agent learn/drop
960
+ // this" is unanswerable later without it. The fact itself stays out
961
+ // of the trace, like every other tool input and output.
962
+ const output = event.result.output;
963
+ const entry = (event.result.toolName === MEMORY_TOOL_NAME || event.result.toolName === FORGET_TOOL_NAME)
964
+ && event.result.ok && typeof output === 'object' && output !== null && !Array.isArray(output)
965
+ && typeof output.id === 'string'
966
+ ? output.id
967
+ : undefined;
968
+ return { tool: event.result.toolName, ok: event.result.ok, ...(entry !== undefined ? { entry } : {}) };
969
+ }
909
970
  case 'tool.approval-requested':
910
971
  return { tool: event.call.toolName, risk: event.risk, requestId: event.requestId };
911
972
  case 'tool.approval-resolved':
@@ -1019,6 +1080,8 @@ const createAgentRuntime = async (streams, options) => {
1019
1080
  const tools = new ToolRegistry();
1020
1081
  tools.register(createDemoTool());
1021
1082
  tools.register(createRememberTool(memory));
1083
+ tools.register(createRecallTool(memory));
1084
+ tools.register(createForgetTool(memory));
1022
1085
  // The same skills the daemon would serve, from the same directory, for
1023
1086
  // the same reason the plugins below match: a skill that routes in
1024
1087
  // `stratus run` routes in `stratus serve`, and one that is broken is
@@ -1769,11 +1832,17 @@ const verifySlackAppToken = async (token, fetchImpl) => {
1769
1832
  // package being installed.
1770
1833
  const SLACK_BOT_SCOPES = [
1771
1834
  'app_mentions:read',
1835
+ // The conversations read family backs outbound destination validation
1836
+ // (conversations.info): whether a channel a schedule wants to report to
1837
+ // exists, and whether this app is a member of it.
1838
+ 'channels:read',
1772
1839
  'chat:write',
1773
1840
  'files:write',
1841
+ 'groups:read',
1774
1842
  'im:history',
1775
1843
  'im:read',
1776
1844
  'im:write',
1845
+ 'mpim:read',
1777
1846
  'users:read',
1778
1847
  ];
1779
1848
  const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
@@ -1803,7 +1872,8 @@ export const slackAppManifest = (agentName) => JSON.stringify({
1803
1872
  const DEFAULT_SOUL_STARTER = [
1804
1873
  'You are a helpful, warm generalist. Answer first, explain second, and',
1805
1874
  'keep replies short unless the question genuinely needs depth. Use',
1806
- 'memory.remember for durable facts about the people you work with.',
1875
+ 'memory.remember for durable facts about the people you work with, and',
1876
+ 'memory.recall to look up what you know when a conversation calls for it.',
1807
1877
  ].join('\n');
1808
1878
  export const runSetup = async (command, streams, env = {}) => {
1809
1879
  const cwd = readWorkingDirectory(env);
@@ -3736,7 +3806,11 @@ const resolveSkillSource = async (source, env) => {
3736
3806
  const localPath = path.resolve(readWorkingDirectory(env), source);
3737
3807
  try {
3738
3808
  if ((await stat(localPath)).isDirectory()) {
3739
- return { kind: 'local', directory: localPath };
3809
+ // The real directory, not the path as typed: a source that is
3810
+ // itself a symlink would otherwise be copied as that link — an
3811
+ // installed entry pointing back at the source tree, which the
3812
+ // loader ignores as not-a-directory.
3813
+ return { kind: 'local', directory: await realpath(localPath) };
3740
3814
  }
3741
3815
  }
3742
3816
  catch {
@@ -3750,8 +3824,30 @@ const resolveSkillSource = async (source, env) => {
3750
3824
  }
3751
3825
  throw new Error(`Cannot read ${JSON.stringify(source)} as a skill source. Pass a GitHub owner/repo, a git URL, or a local path.`);
3752
3826
  };
3827
+ /**
3828
+ * A source URL safe to print: userinfo stripped. A token travels in exactly
3829
+ * this position (`https://user:token@host/repo.git`), stdout is commonly
3830
+ * retained in CI logs, and git's own stderr already redacts — only the
3831
+ * unredacted URL is handed to git itself.
3832
+ */
3833
+ const redactedSourceUrl = (url) => {
3834
+ try {
3835
+ const parsed = new URL(url);
3836
+ if (parsed.username || parsed.password) {
3837
+ parsed.username = '***';
3838
+ parsed.password = '';
3839
+ }
3840
+ return parsed.toString();
3841
+ }
3842
+ catch {
3843
+ // scp-style (git@host:path) or anything else URL() refuses: keep the
3844
+ // shape, hide whatever sits before the @.
3845
+ return url.replace(/^[^@/]+@/, '***@');
3846
+ }
3847
+ };
3753
3848
  /** Shallow-clone a skills source. Git owns every transport we would otherwise re-implement. */
3754
3849
  const cloneSkillSource = async (url, destination) => {
3850
+ const display = redactedSourceUrl(url);
3755
3851
  await new Promise((resolve, reject) => {
3756
3852
  const child = spawn('git', ['clone', '--depth', '1', '--quiet', url, destination], {
3757
3853
  stdio: ['ignore', 'ignore', 'pipe'],
@@ -3761,18 +3857,54 @@ const cloneSkillSource = async (url, destination) => {
3761
3857
  stderr += String(chunk);
3762
3858
  });
3763
3859
  child.on('error', (error) => {
3764
- reject(new Error(`Could not run git to fetch ${url}: ${error.message}`));
3860
+ reject(new Error(`Could not run git to fetch ${display}: ${error.message}`));
3765
3861
  });
3766
3862
  child.on('close', (code) => {
3767
3863
  if (code === 0) {
3768
3864
  resolve();
3769
3865
  }
3770
3866
  else {
3771
- reject(new Error(`git clone failed for ${url}: ${stderr.trim() || `exit code ${code}`}`));
3867
+ // git redacts credentials in its own messages; the URL we echo is
3868
+ // ours to redact.
3869
+ reject(new Error(`git clone failed for ${display}: ${stderr.trim() || `exit code ${code}`}`));
3772
3870
  }
3773
3871
  });
3774
3872
  });
3775
3873
  };
3874
+ /**
3875
+ * The souls skill enablement is judged against: the agents directory plus
3876
+ * the configured default soul (config `soul:` / STRATUS_SOUL), which may
3877
+ * live outside it and is served all the same. The daemon's roster and
3878
+ * `stratus agents` both include it, so `skill add --agent` and `stratus
3879
+ * skills` must not answer from a narrower set.
3880
+ */
3881
+ const rosterSoulsWithConfigured = async (env, warn) => {
3882
+ const entries = await loadRosterSouls(env, warn);
3883
+ let complete = true;
3884
+ try {
3885
+ const configured = await resolveConfiguredSoul({}, env);
3886
+ if (configured) {
3887
+ const entry = { soul: configured.soul, path: configured.path };
3888
+ const clash = entries.findIndex((candidate) => candidate.soul.agent.id === configured.soul.agent.id);
3889
+ // Replaced, not skipped: a roster file claiming the configured
3890
+ // soul's id is the one the gateway stops serving — both commands
3891
+ // must answer for the soul a dispatch actually runs.
3892
+ if (clash >= 0) {
3893
+ entries[clash] = entry;
3894
+ }
3895
+ else {
3896
+ entries.push(entry);
3897
+ }
3898
+ }
3899
+ }
3900
+ catch (error) {
3901
+ // The configured agent's allowlist was not read, so the set is not
3902
+ // the roster — callers making enablement claims must withhold them.
3903
+ complete = false;
3904
+ warn(`could not read the configured soul: ${error instanceof Error ? error.message : String(error)}`);
3905
+ }
3906
+ return { entries, complete };
3907
+ };
3776
3908
  export const runSkillAdd = async (command, streams, env = {}) => {
3777
3909
  const resolved = await resolveSkillSource(command.source, env);
3778
3910
  let sourceDir;
@@ -3783,7 +3915,7 @@ export const runSkillAdd = async (command, streams, env = {}) => {
3783
3915
  else {
3784
3916
  const scratch = await mkdtemp(path.join(os.tmpdir(), 'stratus-skill-add-'));
3785
3917
  cleanup = () => rm(scratch, { recursive: true, force: true });
3786
- writeLine(streams.stdout, `Fetching ${resolved.url} …`);
3918
+ writeLine(streams.stdout, `Fetching ${redactedSourceUrl(resolved.url)} …`);
3787
3919
  try {
3788
3920
  await cloneSkillSource(resolved.url, scratch);
3789
3921
  }
@@ -3794,17 +3926,30 @@ export const runSkillAdd = async (command, streams, env = {}) => {
3794
3926
  sourceDir = scratch;
3795
3927
  }
3796
3928
  try {
3929
+ // For a cloned source the temp directory's random basename must not
3930
+ // name a root-level skill — the repository's own name is what a
3931
+ // nameless root SKILL.md installs under.
3932
+ const rootId = resolved.kind === 'git'
3933
+ ? path.basename(new URL(resolved.url.replace(/^git@([^:]+):/, 'ssh://git@$1/')).pathname, '.git')
3934
+ : undefined;
3797
3935
  const result = await installSkillsFromDirectory(env, sourceDir, {
3798
3936
  ...(command.skillIds ? { only: command.skillIds } : {}),
3799
3937
  ...(command.force ? { force: true } : {}),
3938
+ ...(rootId !== undefined && rootId.length > 0 ? { rootId } : {}),
3800
3939
  });
3801
3940
  for (const skill of result.installed) {
3802
3941
  writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
3803
3942
  }
3943
+ // Present already is a no-op, not a failure — and exactly what the
3944
+ // "rerun with --agent" hint below produces, so these stay eligible
3945
+ // for the enablement step.
3946
+ for (const skill of result.alreadyInstalled) {
3947
+ writeLine(streams.stdout, `already installed ${skill.id} — ${skill.description}`);
3948
+ }
3804
3949
  for (const skip of result.skipped) {
3805
3950
  writeLine(streams.stderr, `Warning: skipped ${skip.id}: ${skip.reason}`);
3806
3951
  }
3807
- if (result.installed.length === 0) {
3952
+ if (result.installed.length === 0 && result.alreadyInstalled.length === 0) {
3808
3953
  writeLine(streams.stderr, result.skipped.length > 0
3809
3954
  ? 'Error: nothing was installed.'
3810
3955
  : `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
@@ -3813,7 +3958,7 @@ export const runSkillAdd = async (command, streams, env = {}) => {
3813
3958
  // Installed is not enabled: a soul opts in through its skills:
3814
3959
  // allowlist, and that stays true when the install came through a
3815
3960
  // command. --agent is the explicit way to say both at once.
3816
- const ids = result.installed.map((skill) => skill.id);
3961
+ const ids = [...result.installed, ...result.alreadyInstalled].map((skill) => skill.id);
3817
3962
  if (command.agentId === undefined) {
3818
3963
  writeLine(streams.stdout);
3819
3964
  writeLine(streams.stdout, 'Installed, not yet enabled. Add to an agent\'s soul frontmatter:');
@@ -3824,10 +3969,10 @@ export const runSkillAdd = async (command, streams, env = {}) => {
3824
3969
  writeLine(streams.stdout, `(or rerun with --agent <id>, or list them: stratus skills)`);
3825
3970
  return 0;
3826
3971
  }
3827
- const roster = await loadRosterSouls(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
3972
+ const { entries: roster } = await rosterSoulsWithConfigured(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
3828
3973
  const entry = roster.find((candidate) => candidate.soul.agent.id === command.agentId);
3829
3974
  if (!entry) {
3830
- writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)}. The skills are installed; enable them by editing a soul.`);
3975
+ writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)} or the configured soul. The skills are installed; enable them by editing a soul.`);
3831
3976
  return 1;
3832
3977
  }
3833
3978
  const existing = entry.soul.agent.skills ?? [];
@@ -3850,6 +3995,53 @@ export const runSkillAdd = async (command, streams, env = {}) => {
3850
3995
  await cleanup?.();
3851
3996
  }
3852
3997
  };
3998
+ /**
3999
+ * List and cancel what the fleet has scheduled — against the daemon's own
4000
+ * database, the way `stratus logs` reads the daemon's own log. WAL makes a
4001
+ * second process on the file routine; a running daemon re-reads due rows
4002
+ * every tick and the destination grant on every send, so a cancel from
4003
+ * here takes effect without asking it anything.
4004
+ */
4005
+ export const runSchedules = async (command, streams, env = {}) => {
4006
+ // Lazy like the serve path: node:sqlite loads only for the command that
4007
+ // needs it.
4008
+ const { SqliteScheduleStore, defaultSessionDbPath } = await import('@stratusagent/gateway');
4009
+ const store = new SqliteScheduleStore(defaultSessionDbPath(env));
4010
+ try {
4011
+ if (command.action === 'cancel') {
4012
+ const id = command.scheduleId ?? '';
4013
+ const record = store.get(id);
4014
+ if (!record || !store.delete(id)) {
4015
+ writeLine(streams.stderr, `No schedule with id ${id}. \`stratus schedules\` lists what exists.`);
4016
+ return 1;
4017
+ }
4018
+ writeLine(streams.stdout, `Cancelled ${id} (${record.agentId}, ${describeCadence(record.cadence)}).`);
4019
+ if (record.destination) {
4020
+ writeLine(streams.stdout, `Its pre-authorized destination ${canonicalDestination(record.destination)} is revoked with it.`);
4021
+ }
4022
+ return 0;
4023
+ }
4024
+ const schedules = store.list();
4025
+ if (command.format === 'json') {
4026
+ writeLine(streams.stdout, JSON.stringify({ schedules: schedules.map(describeSchedule) }, null, 2));
4027
+ return 0;
4028
+ }
4029
+ if (schedules.length === 0) {
4030
+ writeLine(streams.stdout, 'No schedules set. An agent sets one with the schedule.every / schedule.at tools.');
4031
+ return 0;
4032
+ }
4033
+ for (const record of schedules) {
4034
+ const destination = record.destination ? ` → ${canonicalDestination(record.destination)}` : '';
4035
+ writeLine(streams.stdout, `${record.id} [${record.agentId}] ${describeCadence(record.cadence)}${destination}`);
4036
+ writeLine(streams.stdout, ` next: ${record.nextFireAt ?? '(spent — awaiting cleanup)'}${record.lastFiredAt ? ` last: ${record.lastFiredAt}` : ''}`);
4037
+ writeLine(streams.stdout, ` prompt: ${record.prompt}`);
4038
+ }
4039
+ return 0;
4040
+ }
4041
+ finally {
4042
+ store.close();
4043
+ }
4044
+ };
3853
4045
  export const runSkills = async (streams, env = {}) => {
3854
4046
  const registry = new SkillRegistry();
3855
4047
  const skills = await loadOperatorSkills(env, registry, (line) => {
@@ -3864,17 +4056,34 @@ export const runSkills = async (streams, env = {}) => {
3864
4056
  // follows "what is installed" — read from the same roster a dispatch
3865
4057
  // serves. Plugin-contributed skills are the daemon's to list
3866
4058
  // (/catalog/tools); this command reads the operator directory.
3867
- const roster = await loadRosterSouls(env, () => { }).catch(() => []);
4059
+ //
4060
+ // A roster that will not load (a duplicate agent id, deliberately
4061
+ // fatal) is unreadable enablement, not empty enablement: say so and
4062
+ // withhold the claim rather than reporting every skill unused.
4063
+ let roster = [];
4064
+ let rosterUnreadable = false;
4065
+ try {
4066
+ const resolved = await rosterSoulsWithConfigured(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
4067
+ roster = resolved.entries;
4068
+ rosterUnreadable = !resolved.complete;
4069
+ }
4070
+ catch (error) {
4071
+ rosterUnreadable = true;
4072
+ writeLine(streams.stderr, `Warning: cannot say who enables what — the roster did not load: ${error instanceof Error ? error.message : String(error)}`);
4073
+ }
3868
4074
  for (const skill of skills) {
3869
- const enabledBy = roster
3870
- .filter((entry) => {
3871
- const allowlist = entry.soul.agent.skills;
3872
- return allowlist !== undefined
3873
- && allowlist.length > 0
3874
- && matchesSkillAllowlist(skill.id, allowlist);
3875
- })
3876
- .map((entry) => entry.soul.agent.name);
3877
- const suffix = enabledBy.length > 0 ? ` — enabled by ${enabledBy.join(', ')}` : ' — enabled by nobody yet';
4075
+ let suffix = '';
4076
+ if (!rosterUnreadable) {
4077
+ const enabledBy = roster
4078
+ .filter((entry) => {
4079
+ const allowlist = entry.soul.agent.skills;
4080
+ return allowlist !== undefined
4081
+ && allowlist.length > 0
4082
+ && matchesSkillAllowlist(skill.id, allowlist);
4083
+ })
4084
+ .map((entry) => entry.soul.agent.name);
4085
+ suffix = enabledBy.length > 0 ? ` — enabled by ${enabledBy.join(', ')}` : ' — enabled by nobody yet';
4086
+ }
3878
4087
  writeLine(streams.stdout, `${skill.id.padEnd(24)}${skill.description}${suffix}`);
3879
4088
  }
3880
4089
  return 0;
@@ -4450,9 +4659,14 @@ export const runServe = async (command, streams, env = {}) => {
4450
4659
  log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
4451
4660
  },
4452
4661
  };
4453
- const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
4454
- ? { mode: 'remote', request: transport.request, onDecision, commands }
4455
- : { mode: 'headless', onDecision, commands });
4662
+ const approvals = (transport) => createPermissionPolicy(
4663
+ // The destination scope rides along in BOTH modes — it is what lets a
4664
+ // scheduled turn report to the channel a human approved with the
4665
+ // schedule, and headless (where every other gated call is refused) is
4666
+ // exactly the deployment it exists for.
4667
+ approvalMode === 'remote'
4668
+ ? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
4669
+ : { mode: 'headless', onDecision, commands, destinations: transport.destinations });
4456
4670
  if (approvalMode === 'remote') {
4457
4671
  // Only agents whose channel actually came up can be asked: tokens on
4458
4672
  // disk with the Slack package missing means nothing renders the
@@ -4643,6 +4857,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4643
4857
  if (command.command === 'skills') {
4644
4858
  return await runSkills(streams, resolvedEnv);
4645
4859
  }
4860
+ if (command.command === 'schedules') {
4861
+ return await runSchedules(command, streams, resolvedEnv);
4862
+ }
4646
4863
  if (command.command === 'doctor') {
4647
4864
  return await runDoctor(command, streams, resolvedEnv);
4648
4865
  }