@stratusagent/cli 0.3.0 → 0.4.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
@@ -9,8 +9,8 @@ import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@s
9
9
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
10
10
  import { createPermissionPolicy } from '@stratusagent/permissions';
11
11
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
12
- import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
13
- import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
12
+ import { agentIdWithSuffix, createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
13
+ import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
14
14
  import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
15
15
  export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
16
16
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -23,7 +23,7 @@ export { createFileMemoryStore } from '@stratusagent/state';
23
23
  // second copy of a number nobody re-reads: it is drawn in the setup header
24
24
  // and served from the dashboard's /api/status, so drift here misreports the
25
25
  // running build rather than failing anything.
26
- export const CLI_VERSION = '0.3.0';
26
+ export const CLI_VERSION = '0.4.0';
27
27
  const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
28
28
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
29
29
  /**
@@ -97,9 +97,10 @@ Commands:
97
97
  serve Run stratusd, the always-on gateway: durable sessions, the
98
98
  whole roster live at once (each agent on its own provider),
99
99
  delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
100
- (--idle-timeout <seconds>, --no-events, --no-log-file,
101
- --config <path>); everything it says is also written to
102
- ~/.stratus/logs, which "stratus logs" reads
100
+ (--idle-timeout <seconds>, --approvals <headless|remote>,
101
+ --no-events, --no-log-file, --config <path>); everything it
102
+ says is also written to ~/.stratus/logs, which
103
+ "stratus logs" reads
103
104
  service Keep stratusd running under launchd (macOS) or systemd
104
105
  (Linux): install, uninstall, status, start, stop.
105
106
  Installing starts it now and at every login
@@ -132,7 +133,10 @@ Options:
132
133
  --config Config file path (run: load settings from it, setup: write it)
133
134
  --format Output format: text or json (default: text)
134
135
  --no-events Hide event-by-event progress lines in text mode
135
- --approvals Tool approval mode: always, ask, or never (default: always)
136
+ --approvals run/chat: tool approval mode — always, ask, or never (default: always)
137
+ serve: how the daemon reaches a human — headless (refuse every
138
+ gated call) or remote (ask in Slack). Default headless, or
139
+ the config file's "approvals.mode"
136
140
  --max-turns Maximum provider turns per run (default: 8)
137
141
  --port Dashboard port, defaults to an open local port
138
142
  --host Dashboard host (default: 127.0.0.1)
@@ -237,6 +241,15 @@ export const parseCommand = (argv, env = {}) => {
237
241
  index += 1;
238
242
  continue;
239
243
  }
244
+ if (token === '--approvals') {
245
+ const value = readOptionValue(rest, index, '--approvals');
246
+ if (value !== 'headless' && value !== 'remote') {
247
+ throw new Error(`Unsupported approvals mode: ${value}. Use headless or remote.`);
248
+ }
249
+ parsed.approvals = value;
250
+ index += 1;
251
+ continue;
252
+ }
240
253
  if (token === '--no-events') {
241
254
  parsed.events = false;
242
255
  continue;
@@ -656,6 +669,10 @@ export const formatEvent = (event) => {
656
669
  return `• tool.completed ${event.result.toolName} ok=${String(event.result.ok)}`;
657
670
  case 'tool.denied':
658
671
  return `• tool.denied ${event.call.toolName}`;
672
+ case 'tool.approval-requested':
673
+ return `• tool.approval-requested ${event.call.toolName} (${event.risk}) for ${event.agentId}`;
674
+ case 'tool.approval-resolved':
675
+ return `• tool.approval-resolved ${event.answer} (${event.reason})${event.actor ? ` by ${event.actor}` : ''}`;
659
676
  case 'session.completed':
660
677
  return `• session.completed ${event.sessionId}`;
661
678
  case 'session.failed':
@@ -668,6 +685,14 @@ export const formatEvent = (event) => {
668
685
  * The fields worth keeping per event type. Tool inputs and message text
669
686
  * are deliberately excluded: the log is a trace of what happened, not a
670
687
  * second copy of the transcript.
688
+ *
689
+ * The approval pair is the one place the trace carries a person's id, and
690
+ * it earns it: `always` widens what an agent may do unattended for the rest
691
+ * of the session, and "who decided that" is unanswerable afterwards from
692
+ * anything else. The refusal path is warned about by `onDecision`, but an
693
+ * *approval* produces no warning at all — without these two records a
694
+ * granted permission leaves no trace whatsoever. Still no tool input: what
695
+ * was asked is here, what it was asked with is not.
671
696
  */
672
697
  const eventDetail = (event) => {
673
698
  switch (event.type) {
@@ -680,14 +705,30 @@ const eventDetail = (event) => {
680
705
  return { tool: event.call.toolName };
681
706
  case 'tool.completed':
682
707
  return { tool: event.result.toolName, ok: event.result.ok };
708
+ case 'tool.approval-requested':
709
+ return { tool: event.call.toolName, risk: event.risk, requestId: event.requestId };
710
+ case 'tool.approval-resolved':
711
+ return {
712
+ requestId: event.requestId,
713
+ answer: event.answer,
714
+ reason: event.reason,
715
+ ...(event.actor ? { actor: event.actor } : {}),
716
+ };
683
717
  case 'session.failed':
684
718
  return { error: event.error };
685
719
  default:
686
720
  return undefined;
687
721
  }
688
722
  };
689
- // Kept with its historical CLI signature: a parsed run command is a
690
- // RuntimeSelection plus CLI-only fields the resolver ignores.
723
+ /**
724
+ * Kept with its historical CLI signature: a parsed run command is a
725
+ * RuntimeSelection plus CLI-only fields the resolver ignores.
726
+ *
727
+ * Those fields are *permitted*, not required. Demanding a whole
728
+ * `ParsedRunCommand` made the type claim the resolver cared about
729
+ * `approvals`, `format`, and `events` — it reads none of them — so every
730
+ * caller with a selection in hand had to invent values to get past it.
731
+ */
691
732
  export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
692
733
  /**
693
734
  * A saved subscription sign-in silently demoted to per-token billing is the
@@ -1691,7 +1732,12 @@ export const slackAppManifest = (agentName) => JSON.stringify({
1691
1732
  oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
1692
1733
  settings: {
1693
1734
  event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
1694
- interactivity: { is_enabled: false },
1735
+ // Required for remote approval: Allow / Always allow / Deny arrive as
1736
+ // block_actions, and Slack delivers none of them to an app that has
1737
+ // interactivity switched off. Enabled unconditionally rather than per
1738
+ // mode — an app is created once, and discovering months later that the
1739
+ // buttons do nothing means editing the manifest and reinstalling.
1740
+ interactivity: { is_enabled: true },
1695
1741
  org_deploy_enabled: false,
1696
1742
  socket_mode_enabled: true,
1697
1743
  token_rotation_enabled: false,
@@ -1714,6 +1760,104 @@ const KNOWN_CLAUDE_MODELS = [
1714
1760
  'claude-opus-4-6',
1715
1761
  'claude-sonnet-4-6',
1716
1762
  ];
1763
+ /**
1764
+ * The ids a newly created soul must not claim: every id the served roster
1765
+ * holds, which is three things and not one directory.
1766
+ *
1767
+ * - **What the roster files declare.** A filename is not an id: a soul at
1768
+ * `renamed.md` may declare `id: ava`, so `ava.md` being free proves
1769
+ * nothing — and since a duplicate refuses the whole roster, writing one
1770
+ * would hand back an agent whose daemon cannot start.
1771
+ * - **The configured default soul**, which the daemon registers whether or
1772
+ * not its file lives in the agents directory. It wins a same-id contest
1773
+ * with a roster file — `defaultAgentId` replaces the source when the
1774
+ * path differs — so a new agent sharing its id is not refused, it is
1775
+ * shadowed: created, then undispatchable by id or from Slack, with
1776
+ * nothing saying so.
1777
+ * - **The reserved `stratus`**, since a roster soul claiming it is skipped
1778
+ * at load, so writing one creates an agent that silently never appears.
1779
+ *
1780
+ * The two readable sources fail **independently**, and what they return is
1781
+ * what they know rather than all-or-nothing. A configured soul that is
1782
+ * missing or mid-edit does not stop the daemon serving the roster, so it
1783
+ * must not discard the roster's claims either: collapsing both to unknown
1784
+ * would let this command write the very duplicate that refuses the roster
1785
+ * it could have read. `unread` names what could not be answered, so the
1786
+ * caller can say which check did not run instead of implying none did.
1787
+ */
1788
+ const declaredAgentIds = async (env) => {
1789
+ const [roster, configured] = await Promise.allSettled([
1790
+ loadRosterSouls(env, () => { }),
1791
+ // Empty selection: the soul a run started here would resolve, by the
1792
+ // same precedence the daemon uses — never a second reading of it.
1793
+ resolveConfiguredSoul({}, env),
1794
+ ]);
1795
+ const ids = new Set([DEFAULT_STRATUS_AGENT.id]);
1796
+ const unread = [];
1797
+ if (roster.status === 'fulfilled') {
1798
+ for (const entry of roster.value) {
1799
+ ids.add(entry.soul.agent.id);
1800
+ }
1801
+ }
1802
+ else {
1803
+ unread.push('the roster');
1804
+ }
1805
+ if (configured.status === 'fulfilled') {
1806
+ if (configured.value) {
1807
+ ids.add(configured.value.soul.agent.id);
1808
+ }
1809
+ }
1810
+ else {
1811
+ unread.push('the configured default soul');
1812
+ }
1813
+ return { ids, unread };
1814
+ };
1815
+ /**
1816
+ * Write a new soul under an id nothing else holds, and return the agent
1817
+ * that got written.
1818
+ *
1819
+ * The name stays theirs, but the id — the soul filename, the memory key,
1820
+ * the credential scope — must be unique: the suggestion pool is small, so
1821
+ * a repeat name would otherwise share an earlier agent's memory. Two
1822
+ * claims have to fail here, and only one of them is a filename:
1823
+ *
1824
+ * - An id another soul declares, whatever that soul is called on disk.
1825
+ * Since a duplicate refuses the whole roster, writing one would leave a
1826
+ * daemon that will not start — created by the command meant to help.
1827
+ * - The path itself, via `wx`, which makes the claim atomic against a
1828
+ * concurrent writer that the roster read above cannot see.
1829
+ *
1830
+ * On either, the id takes a fresh suffix and we try again — through the
1831
+ * shared bound, since appending a suffix to a maxed-out base would build
1832
+ * an id the validator refuses, turning a collision into a crash.
1833
+ */
1834
+ const claimSoulFile = async (env, input, render, note) => {
1835
+ const { ids: taken, unread } = await declaredAgentIds(env);
1836
+ if (unread.length > 0) {
1837
+ note(`Note: could not read ${unread.join(' or ')}, so this id was not checked against the ids it declares.`);
1838
+ }
1839
+ let agent = defineAgent({ ...(input.name ? { name: input.name } : {}), instructions: input.instructions });
1840
+ const baseId = agent.id;
1841
+ for (;;) {
1842
+ const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
1843
+ if (!taken.has(agent.id)) {
1844
+ try {
1845
+ await writeFile(soulPath, render(agent), { flag: 'wx' });
1846
+ return { agent, soulPath };
1847
+ }
1848
+ catch (error) {
1849
+ if (error.code !== 'EEXIST') {
1850
+ throw error;
1851
+ }
1852
+ }
1853
+ }
1854
+ agent = defineAgent({
1855
+ id: agentIdWithSuffix(baseId, randomUUID().slice(0, 4)),
1856
+ ...(input.name ? { name: input.name } : { name: agent.name }),
1857
+ instructions: input.instructions,
1858
+ });
1859
+ }
1860
+ };
1717
1861
  export const runSetup = async (command, streams, env = {}) => {
1718
1862
  const cwd = readWorkingDirectory(env);
1719
1863
  const processEnv = readProcessEnv(env);
@@ -2298,17 +2442,13 @@ export const runSetup = async (command, streams, env = {}) => {
2298
2442
  }
2299
2443
  const name = await prompter.ask('Name your agent (Enter to have one generated): ');
2300
2444
  const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
2301
- const agent = defineAgent({
2302
- ...(name ? { name } : {}),
2303
- instructions: instructions || DEFAULT_SOUL_STARTER,
2304
- });
2305
- const soul = formatSoul({
2306
- agent,
2307
- ...(state.provider !== 'demo' ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) } : {}),
2308
- });
2309
- const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2310
- await mkdir(path.dirname(soulPath), { recursive: true });
2311
- await writeFile(soulPath, soul);
2445
+ const persona = instructions || DEFAULT_SOUL_STARTER;
2446
+ const pin = state.provider !== 'demo'
2447
+ ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) }
2448
+ : {};
2449
+ await mkdir(agentsDirPath(env), { recursive: true });
2450
+ const claimed = await claimSoulFile(env, { ...(name ? { name } : {}), instructions: persona }, (agent) => formatSoul({ agent, ...pin }), (message) => writeLine(streams.stdout, message));
2451
+ const { agent, soulPath } = claimed;
2312
2452
  state.soulPath = soulPath;
2313
2453
  writeLine(streams.stdout, `Say hello to ${agent.name}.`);
2314
2454
  writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
@@ -2500,19 +2640,31 @@ export const runSetup = async (command, streams, env = {}) => {
2500
2640
  // built-in's place — the gateway skips it, so offering it here would
2501
2641
  // name an app after an agent that never receives the messages.
2502
2642
  const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
2503
- // Mirror the gateway's loadRoster: two soul files declaring the same
2504
- // id are not two agents. Offering both would let someone create and
2505
- // name an app for the loser, which can never receive a message —
2506
- // Slack credentials are keyed by the shared id, and dispatch reaches
2507
- // the first sorted file.
2508
- for (const entry of await loadRosterSouls(env, warnOnce)) {
2509
- const clash = entries.find((seen) => seen.soul.agent.id === entry.soul.agent.id);
2510
- if (clash) {
2511
- warnOnce(`duplicate agent id ${entry.soul.agent.id} (${clash.path ?? 'built-in'} vs ${entry.path}); keeping the first`);
2512
- continue;
2513
- }
2514
- entries.push(entry);
2643
+ // loadRosterSouls refuses a roster whose files collide, and drops the
2644
+ // ones claiming the reserved built-in id, so what comes back here is
2645
+ // already unambiguous.
2646
+ let rosterSouls = [];
2647
+ let rosterLoaded = true;
2648
+ try {
2649
+ rosterSouls = await loadRosterSouls(env, warnOnce);
2515
2650
  }
2651
+ catch (error) {
2652
+ // A roster that cannot say who its agents are cannot have channels
2653
+ // configured for them — but the rest of setup (providers, models,
2654
+ // sign-ins) still works, so this reports and moves on rather than
2655
+ // taking the whole command down.
2656
+ rosterLoaded = false;
2657
+ warnOnce(error instanceof Error ? error.message : String(error));
2658
+ // Nothing is offered, not merely the colliding pair. A roster that
2659
+ // refuses to load fails `createGateway.start()` outright, so no
2660
+ // agent is servable — not the built-in seeded above, and not the
2661
+ // configured soul resolved below (which can itself BE one of the
2662
+ // colliding files). Listing any of them invites connecting a Slack
2663
+ // app to an agent the daemon cannot bring online, and connecting an
2664
+ // app is the expensive half of that mistake.
2665
+ return { entries: [], loaded: false };
2666
+ }
2667
+ entries.push(...rosterSouls);
2516
2668
  // The soul a run resolves to, in resolveSoulPath's own order: an env
2517
2669
  // override outranks the config value this setup session is editing.
2518
2670
  // Listing the config soul while `stratus serve` registers the env one
@@ -2525,11 +2677,11 @@ export const runSetup = async (command, streams, env = {}) => {
2525
2677
  }
2526
2678
  const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
2527
2679
  if (!effectiveSoul) {
2528
- return entries;
2680
+ return { entries, loaded: rosterLoaded };
2529
2681
  }
2530
2682
  const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
2531
2683
  if (entries.some((entry) => entry.path === resolved)) {
2532
- return entries;
2684
+ return { entries, loaded: rosterLoaded };
2533
2685
  }
2534
2686
  try {
2535
2687
  const soul = await loadSoulFile(resolved);
@@ -2548,7 +2700,7 @@ export const runSetup = async (command, streams, env = {}) => {
2548
2700
  catch (error) {
2549
2701
  warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
2550
2702
  }
2551
- return entries;
2703
+ return { entries, loaded: rosterLoaded };
2552
2704
  };
2553
2705
  const serviceSummary = () => {
2554
2706
  if (!servicePlatform(serviceEnvFor(env))) {
@@ -2594,7 +2746,7 @@ export const runSetup = async (command, streams, env = {}) => {
2594
2746
  };
2595
2747
  const chooseChannels = async () => {
2596
2748
  while (true) {
2597
- const roster = await channelRoster();
2749
+ const { entries: roster, loaded: rosterLoaded } = await channelRoster();
2598
2750
  const slack = state.channels.slack ?? {};
2599
2751
  // The roster always holds at least the built-in agent, so there is
2600
2752
  // no empty state to short-circuit on — and short-circuiting would
@@ -2606,7 +2758,19 @@ export const runSetup = async (command, streams, env = {}) => {
2606
2758
  });
2607
2759
  // Orphans: tokens whose agent left the roster would otherwise be
2608
2760
  // invisible here while still being loaded by `stratus serve`.
2609
- const orphans = Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id));
2761
+ //
2762
+ // Only when the roster actually loaded. "No agent has this id" is a
2763
+ // claim about the roster, and a roster that refused to load cannot
2764
+ // support it — every stored token would look orphaned, and this list
2765
+ // offers to DELETE them. Losing a working agent's Slack credentials
2766
+ // because a different pair of files collided is a far worse outcome
2767
+ // than leaving a real orphan on screen for one run.
2768
+ const orphans = rosterLoaded
2769
+ ? Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id))
2770
+ : [];
2771
+ if (!rosterLoaded) {
2772
+ writeLine(streams.stderr, 'Warning: no agents are offered while the roster is unreadable — the daemon cannot start either. Fix the roster first.');
2773
+ }
2610
2774
  for (const id of orphans) {
2611
2775
  options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
2612
2776
  }
@@ -3306,12 +3470,28 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3306
3470
  }
3307
3471
  // Tokens keyed to an agent the roster no longer has are loaded and
3308
3472
  // skipped on every start, silently.
3309
- const rosterIds = new Set((await loadRosterSouls(env, warn)).map((entry) => entry.soul.agent.id));
3473
+ let rosterEntries = [];
3474
+ let rosterLoaded = true;
3475
+ try {
3476
+ rosterEntries = await loadRosterSouls(env, warn);
3477
+ }
3478
+ catch (error) {
3479
+ // Doctor exists to name problems, so a roster it cannot load is a
3480
+ // finding to report — not an exception that replaces the report.
3481
+ rosterLoaded = false;
3482
+ problems.push(error instanceof Error ? error.message : String(error));
3483
+ }
3484
+ const rosterIds = new Set(rosterEntries.map((entry) => entry.soul.agent.id));
3310
3485
  rosterIds.add(DEFAULT_STRATUS_AGENT.id);
3311
3486
  if (soul) {
3312
3487
  rosterIds.add(soul.agent.id);
3313
3488
  }
3314
- const orphans = slackAgents.filter((id) => !rosterIds.has(id));
3489
+ // Only against a roster that actually loaded. "No agent has this id" is
3490
+ // a claim about the roster, and a refused one cannot support it — every
3491
+ // stored token would be reported as orphaned, and this advises clearing
3492
+ // them. Advice is not deletion, but an operator who follows it loses the
3493
+ // same credentials by hand.
3494
+ const orphans = rosterLoaded ? slackAgents.filter((id) => !rosterIds.has(id)) : [];
3315
3495
  if (orphans.length > 0) {
3316
3496
  problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
3317
3497
  + 'Clear them from `stratus setup` → Channels.');
@@ -3476,7 +3656,11 @@ const servedRuntimes = async (env, configPath) => {
3476
3656
  configPresent: location !== undefined,
3477
3657
  };
3478
3658
  const passes = [{ selection: {}, env }];
3479
- for (const entry of await loadRosterSouls(env, () => { })) {
3659
+ // A roster that will not load is the gateway's to refuse, with a better
3660
+ // message than this preflight could give — so it checks what it can and
3661
+ // leaves the failing to start().
3662
+ const rosterForRuntimes = await loadRosterSouls(env, () => { }).catch(() => []);
3663
+ for (const entry of rosterForRuntimes) {
3480
3664
  const normalized = applySoulPins(entry.soul, {}, env, context);
3481
3665
  passes.push({ selection: normalized.selection, env: normalized.env });
3482
3666
  }
@@ -3491,6 +3675,77 @@ const servedRuntimes = async (env, configPath) => {
3491
3675
  }
3492
3676
  return resolved;
3493
3677
  };
3678
+ /**
3679
+ * The daemon's `approvals` block, from the config file the daemon itself
3680
+ * would load. Discovery goes through the shared resolver rather than
3681
+ * reading ~/.stratus/config.json directly: `--config` and STRATUS_CONFIG
3682
+ * both move it, and a second copy of that precedence would resolve the
3683
+ * approver set from a file the gateway is not running on.
3684
+ *
3685
+ * **Only from a trusted location.** An auto-discovered project-local
3686
+ * `stratus.config.json` outranks the global one and can be checked into any
3687
+ * repository — which is why stored credentials are already never combined
3688
+ * with an endpoint it selects. This block is the same kind of boundary and
3689
+ * a sharper one: it names the people who may authorize an agent's gated
3690
+ * tool calls, and it would do so through Slack tokens the user configured
3691
+ * globally. A cloned repo must not be able to appoint its own approver, so
3692
+ * an untrusted config's approvals are ignored — loudly, since silently
3693
+ * dropping the block someone is looking at is its own kind of wrong.
3694
+ *
3695
+ * An unreadable config degrades to headless with a warning, matching how
3696
+ * every other consumer treats one: refusing to start would take the whole
3697
+ * fleet down over a policy block that may not even be present.
3698
+ */
3699
+ const loadServeApprovals = async (env, configPath, warn) => {
3700
+ try {
3701
+ const location = await resolveConfigLocation(configPath ? { configPath } : {}, env);
3702
+ if (!location) {
3703
+ return {};
3704
+ }
3705
+ const approvals = (await loadConfigFile(location.path)).approvals;
3706
+ if (!approvals) {
3707
+ return {};
3708
+ }
3709
+ if (!location.trusted) {
3710
+ warn(`ignoring the approvals config in ${location.path}: a project-local config cannot decide who may approve `
3711
+ + 'this daemon\'s tool calls. Move it to ~/.stratus/config.json, or pass it with --config.');
3712
+ return {};
3713
+ }
3714
+ return approvals;
3715
+ }
3716
+ catch (error) {
3717
+ warn(`ignoring the approvals config (${error instanceof Error ? error.message : String(error)}); refusing gated calls`);
3718
+ return {};
3719
+ }
3720
+ };
3721
+ /**
3722
+ * One line naming who can actually answer, so a remote daemon does not look
3723
+ * configured when it is not. Two ways it can be hollow, and they fail
3724
+ * differently, so they read differently:
3725
+ *
3726
+ * - No channel running at all (no Slack tokens, or the optional package is
3727
+ * not installed) — nothing renders the request, so the turn waits out the
3728
+ * whole timeout before being denied. That is the bad one, and the only
3729
+ * place it is visible is here, at startup.
3730
+ * - A channel is running but an agent has no approvers — the adapter denies
3731
+ * that agent's requests on arrival, which is at least prompt.
3732
+ *
3733
+ * `agentIds` is therefore the agents that can actually be *asked*, not
3734
+ * every agent with tokens on disk.
3735
+ */
3736
+ const describeApprovers = (approvals, agentIds) => {
3737
+ if (agentIds.length === 0) {
3738
+ return 'but no channel is running to ask through, so gated calls will wait out the approval timeout and then be denied';
3739
+ }
3740
+ const covered = agentIds.filter((agentId) => (resolveAgentApprovals(approvals, agentId).slackApprovers ?? []).length > 0);
3741
+ if (covered.length === 0) {
3742
+ return 'but no approvers are configured, so every gated call is denied on arrival';
3743
+ }
3744
+ const uncovered = agentIds.filter((agentId) => !covered.includes(agentId));
3745
+ return uncovered.length === 0
3746
+ ? `approvers set for ${covered.join(', ')}`
3747
+ : `approvers set for ${covered.join(', ')}; none for ${uncovered.join(', ')}, whose calls are denied on arrival`;
3748
+ };
3494
3749
  /**
3495
3750
  * `stratus service` — run the daemon under launchd or systemd, so it
3496
3751
  * survives logout, crashes, and reboots. `serve` itself stays a plain
@@ -3604,7 +3859,6 @@ export const runAgentNew = async (command, streams, env = {}) => {
3604
3859
  const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
3605
3860
  const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
3606
3861
  const persona = instructions || DEFAULT_SOUL_STARTER;
3607
- let agent = defineAgent({ name, instructions: persona });
3608
3862
  // Frontmatter pins what a run from this directory would actually use:
3609
3863
  // env vars outrank the active config file (project-local or explicit
3610
3864
  // first, global otherwise), and no provider anywhere falls back to
@@ -3629,27 +3883,9 @@ export const runAgentNew = async (command, streams, env = {}) => {
3629
3883
  ?? (configModelApplies ? activeConfig.model : undefined)
3630
3884
  ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
3631
3885
  const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
3632
- // The name stays theirs, but the id — the soul filename and the
3633
- // memory key — must be unique: the suggestion pool is small, so a
3634
- // repeat name would otherwise silently overwrite an earlier agent's
3635
- // soul and share their memory. 'wx' makes each claim atomic; on a
3636
- // collision the id gets a fresh suffix and we try again.
3637
3886
  await mkdir(agentsDirPath(env), { recursive: true });
3638
- const baseId = agent.id;
3639
- let soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
3640
- for (;;) {
3641
- try {
3642
- await writeFile(soulPath, formatSoul({ agent, ...soulPin }), { flag: 'wx' });
3643
- break;
3644
- }
3645
- catch (error) {
3646
- if (error.code !== 'EEXIST') {
3647
- throw error;
3648
- }
3649
- agent = defineAgent({ id: `${baseId}-${randomUUID().slice(0, 4)}`, name, instructions: persona });
3650
- soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
3651
- }
3652
- }
3887
+ const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
3888
+ const { agent, soulPath } = claimed;
3653
3889
  const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
3654
3890
  'Yes — every stratus run talks to them',
3655
3891
  'Not now',
@@ -3812,6 +4048,11 @@ export const runServe = async (command, streams, env = {}) => {
3812
4048
  // tokens are gateway infrastructure secrets in the channels namespace of
3813
4049
  // ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
3814
4050
  // for the 2-minute per-agent app setup).
4051
+ // The approval policy and the Slack approver sets come from the same
4052
+ // config block, resolved once here: the daemon must not answer "who can
4053
+ // approve this" differently from "is anyone being asked at all".
4054
+ const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
4055
+ const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
3815
4056
  const channelCredentials = await loadChannelCredentials(env);
3816
4057
  const slackAgents = Object.entries(channelCredentials.slack ?? {});
3817
4058
  const channels = [];
@@ -3824,11 +4065,16 @@ export const runServe = async (command, streams, env = {}) => {
3824
4065
  const adapter = await loadSlackAdapter();
3825
4066
  if (adapter) {
3826
4067
  channels.push(adapter({
3827
- agents: slackAgents.map(([agentId, tokens]) => ({
3828
- agentId,
3829
- appToken: tokens.appToken,
3830
- botToken: tokens.botToken,
3831
- })),
4068
+ agents: slackAgents.map(([agentId, tokens]) => {
4069
+ const route = resolveAgentApprovals(approvalsConfig, agentId);
4070
+ return {
4071
+ agentId,
4072
+ appToken: tokens.appToken,
4073
+ botToken: tokens.botToken,
4074
+ ...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
4075
+ ...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
4076
+ };
4077
+ }),
3832
4078
  log,
3833
4079
  warn,
3834
4080
  }));
@@ -3876,23 +4122,36 @@ export const runServe = async (command, streams, env = {}) => {
3876
4122
  // while `serve` was something you ran in a terminal you were sitting at;
3877
4123
  // it is not, now that setup installs it under launchd by default.
3878
4124
  //
3879
- // Headless is the only honest mode here until a turn can park and ask
3880
- // through a channel: there is no terminal behind a service manager, so
3881
- // `safe` runs and anything riskier is refused rather than waved through.
3882
- // Every refusal is logged — an unattended denial that appears nowhere
3883
- // reads like an agent that decided not to bother.
3884
- const approvals = createPermissionPolicy({
3885
- mode: 'headless',
3886
- onDecision: (decision) => {
3887
- if (decision.allowed) {
3888
- return;
3889
- }
3890
- warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
3891
- },
3892
- });
4125
+ // There is no terminal behind a service manager, so the choice is
4126
+ // between refusing every gated call (`headless`) and parking the turn to
4127
+ // ask through a channel (`remote`). Headless stays the default: a daemon
4128
+ // that starts waiting on people who were never told they are on the hook
4129
+ // hangs turns instead of refusing them. Every refusal is logged — an
4130
+ // unattended denial that appears nowhere reads like an agent that decided
4131
+ // not to bother.
4132
+ const onDecision = (decision) => {
4133
+ if (decision.allowed) {
4134
+ return;
4135
+ }
4136
+ warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
4137
+ };
4138
+ // A factory, not a policy: remote mode parks turns on a transport the
4139
+ // gateway owns, so the policy cannot exist until the gateway is building
4140
+ // it. Headless takes the same path so there is one construction site.
4141
+ const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
4142
+ ? { mode: 'remote', request: transport.request, onDecision }
4143
+ : { mode: 'headless', onDecision });
4144
+ if (approvalMode === 'remote') {
4145
+ // Only agents whose channel actually came up can be asked: tokens on
4146
+ // disk with the Slack package missing means nothing renders the
4147
+ // request, and the turn discovers that by hanging.
4148
+ const askable = channels.length > 0 ? slackAgents.map(([agentId]) => agentId) : [];
4149
+ log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
4150
+ }
3893
4151
  const gateway = createGateway({
3894
4152
  env,
3895
4153
  approvals,
4154
+ ...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
3896
4155
  ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
3897
4156
  ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
3898
4157
  ...(channels.length > 0 ? { channels } : {}),
@@ -3959,6 +4218,21 @@ export const runServe = async (command, streams, env = {}) => {
3959
4218
  });
3960
4219
  }
3961
4220
  await gateway.start();
4221
+ // The roster is only known once the gateway has loaded it, and in remote
4222
+ // mode an agent no channel can ask for is the quietest failure this
4223
+ // feature has: its gated calls park with nobody rendering them and wait
4224
+ // out the whole timeout before being denied. No channel can detect this
4225
+ // on its own — a request is a broadcast, and no adapter knows whether
4226
+ // another one is about to answer — so it is reported here, where the
4227
+ // roster and the channel list are both in view.
4228
+ if (approvalMode === 'remote') {
4229
+ const askable = new Set(channels.length > 0 ? slackAgents.map(([agentId]) => agentId) : []);
4230
+ const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
4231
+ if (unreachable.length > 0) {
4232
+ warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
4233
+ + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
4234
+ }
4235
+ }
3962
4236
  writeLine(streams.stdout, 'Press Ctrl+C to stop.');
3963
4237
  // And periodically, for a long-running daemon that warns steadily
3964
4238
  // without ever writing enough records to rotate. Unref'd, so it never