@stratusagent/cli 0.2.5 → 0.3.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
@@ -7,16 +7,62 @@ import path from 'node:path';
7
7
  import { createInterface } from 'node:readline';
8
8
  import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
9
9
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
10
+ import { createPermissionPolicy } from '@stratusagent/permissions';
10
11
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
11
12
  import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
12
- import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveRuntimeConfig as resolveStateRuntimeConfig, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
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';
14
+ import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
15
+ export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
16
+ import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
17
+ export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, parseLogLines, readRecentRecords, tailLog, } from "./logs.js";
13
18
  // The shared state package owns config resolution, credentials, memory, and
14
19
  // provider wiring now (the gateway uses the same code); the CLI re-exports
15
20
  // its historical surface so existing importers keep working.
16
21
  export { createFileMemoryStore } from '@stratusagent/state';
17
- export const CLI_VERSION = '0.2.5';
22
+ // Kept in step with this package's own version by a test, because it is a
23
+ // second copy of a number nobody re-reads: it is drawn in the setup header
24
+ // and served from the dashboard's /api/status, so drift here misreports the
25
+ // running build rather than failing anything.
26
+ export const CLI_VERSION = '0.3.0';
18
27
  const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
19
28
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
29
+ /**
30
+ * The range every package declares in its `engines` field. Two floors, not
31
+ * one: `node:sqlite` was unflagged in 22.13.0 on the LTS line and in 23.4.0
32
+ * on the 23.x line, so 23.0 through 23.3 are NEWER than the 22.x floor and
33
+ * still ship it behind `--experimental-sqlite`. A plain `>=22.13` admits
34
+ * exactly those releases.
35
+ */
36
+ export const SUPPORTED_NODE_RANGE = '>=22.13 <23 || >=23.4';
37
+ /**
38
+ * Why the CLI checks a version its manifests already declare: `engines` is
39
+ * advisory. npm and pnpm print EBADENGINE and carry on unless the user has
40
+ * turned on engine-strict, so an install on Node 20 succeeds and the floor
41
+ * is discovered later as an ERR_UNKNOWN_BUILTIN_MODULE for `node:sqlite`,
42
+ * thrown from a lazy import inside whichever command first needed the
43
+ * session store. That error names neither Node nor the version required.
44
+ *
45
+ * Returns the message to print, or undefined when the version is fine —
46
+ * including when it cannot be parsed at all, since an unrecognized build
47
+ * string is a bad reason to refuse to run.
48
+ */
49
+ export const unsupportedNodeMessage = (version) => {
50
+ const parts = version.replace(/^v/, '').split('.');
51
+ const major = Number.parseInt(parts[0] ?? '', 10);
52
+ const minor = Number.parseInt(parts[1] ?? '', 10);
53
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) {
54
+ return undefined;
55
+ }
56
+ const supported = major > 23
57
+ || (major === 23 && minor >= 4)
58
+ || (major === 22 && minor >= 13);
59
+ if (supported) {
60
+ return undefined;
61
+ }
62
+ return `Stratus Agent needs Node 22.13 or newer, and 23.4 or newer on the 23.x line — this is Node ${version.replace(/^v/, '')}.\n`
63
+ + "The gateway's session store uses node:sqlite, unflagged in 22.13.0 and, on the 23.x line, not until 23.4.0.\n"
64
+ + 'Upgrade with `brew install node` on macOS, or your package manager or nvm on Linux.';
65
+ };
20
66
  const HELP_TEXT = `Stratus Agent CLI
21
67
 
22
68
  Usage:
@@ -31,24 +77,43 @@ Usage:
31
77
  STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
32
78
  stratus run --config ./stratus.config.json --provider openai "Say hello"
33
79
  stratus agents
80
+ stratus doctor
81
+ stratus service install
82
+ stratus service status
83
+ stratus logs -f
84
+ stratus logs --agent ava -n 200
34
85
  stratus dashboard
35
86
  stratus dashboard --port 4123 --host 0.0.0.0 --no-open
36
87
 
37
88
  Commands:
38
89
  setup Menu-driven onboarding: pick a provider, sign in (Claude
39
- subscription or API key), create your agent, and test it —
40
- settings go to ~/.stratus/config.json, sign-ins to
41
- ~/.stratus/credentials.json (0600)
90
+ subscription or API key), create your agent, connect it to
91
+ Slack, and test it — settings go to ~/.stratus/config.json,
92
+ sign-ins and channel tokens to ~/.stratus/credentials.json
93
+ (0600)
42
94
  chat Talk with your agent — the conversation persists across turns
43
95
  and remembered facts accumulate; /exit or Ctrl+C to leave
44
96
  run Execute one local Stratus Agent session
45
97
  serve Run stratusd, the always-on gateway: durable sessions, the
46
98
  whole roster live at once (each agent on its own provider),
47
99
  delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
48
- (--idle-timeout <seconds>, --no-events, --config <path>)
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
103
+ service Keep stratusd running under launchd (macOS) or systemd
104
+ (Linux): install, uninstall, status, start, stop.
105
+ Installing starts it now and at every login
106
+ (--no-login installs without the login trigger,
107
+ --config <path> pins the daemon to one config file)
108
+ logs Read the daemon's log from any terminal: -f to follow,
109
+ -n <count> for backlog, --agent / --session to filter,
110
+ --format json for the raw records
49
111
  agent new Create an agent identity (generates a human-ish name + avatar theme)
50
112
  agents List your agents: who they are, where their souls live, what
51
113
  they run on, what they remember (also: stratus agent list)
114
+ doctor Show what a run would use right now — provider, model, soul —
115
+ and which file or environment variable decided each, then
116
+ flag anything that would surprise you (--format json)
52
117
  dashboard Start the local Stratus Agent dashboard and open it in your browser
53
118
  help Show this help message
54
119
 
@@ -176,6 +241,10 @@ export const parseCommand = (argv, env = {}) => {
176
241
  parsed.events = false;
177
242
  continue;
178
243
  }
244
+ if (token === '--no-log-file') {
245
+ parsed.logToFile = false;
246
+ continue;
247
+ }
179
248
  throw new Error(`Unknown option: ${token}`);
180
249
  }
181
250
  return parsed;
@@ -265,6 +334,129 @@ export const parseCommand = (argv, env = {}) => {
265
334
  }
266
335
  return { command: 'agents', format };
267
336
  }
337
+ if (command === 'doctor') {
338
+ let format = 'text';
339
+ let configPath;
340
+ for (let index = 0; index < rest.length; index += 1) {
341
+ const token = rest[index];
342
+ if (!token) {
343
+ continue;
344
+ }
345
+ if (token === '--help' || token === '-h') {
346
+ return { command: 'help' };
347
+ }
348
+ if (token === '--format') {
349
+ const value = readOptionValue(rest, index, '--format');
350
+ if (value !== 'text' && value !== 'json') {
351
+ throw new Error(`Unsupported format: ${value}`);
352
+ }
353
+ format = value;
354
+ index += 1;
355
+ continue;
356
+ }
357
+ if (token === '--config') {
358
+ configPath = readOptionValue(rest, index, '--config');
359
+ index += 1;
360
+ continue;
361
+ }
362
+ throw new Error(`Unknown option: ${token}`);
363
+ }
364
+ return { command: 'doctor', format, ...(configPath ? { configPath } : {}) };
365
+ }
366
+ if (command === 'service') {
367
+ const action = rest[0];
368
+ if (action === '--help' || action === '-h' || action === undefined) {
369
+ return { command: 'help' };
370
+ }
371
+ if (action !== 'install' && action !== 'uninstall' && action !== 'status' && action !== 'start' && action !== 'stop') {
372
+ throw new Error(`Unknown service action: ${action} (expected install, uninstall, status, start, or stop)`);
373
+ }
374
+ const parsed = { command: 'service', action };
375
+ const serviceRest = rest.slice(1);
376
+ for (let index = 0; index < serviceRest.length; index += 1) {
377
+ const token = serviceRest[index];
378
+ if (!token) {
379
+ continue;
380
+ }
381
+ if (token === '--help' || token === '-h') {
382
+ return { command: 'help' };
383
+ }
384
+ if (token === '--no-login') {
385
+ if (action !== 'install') {
386
+ throw new Error('--no-login applies to `stratus service install`.');
387
+ }
388
+ parsed.runAtLogin = false;
389
+ continue;
390
+ }
391
+ if (token === '--config') {
392
+ if (action !== 'install') {
393
+ throw new Error('--config applies to `stratus service install`.');
394
+ }
395
+ parsed.configPath = readOptionValue(serviceRest, index, '--config');
396
+ index += 1;
397
+ continue;
398
+ }
399
+ throw new Error(`Unknown option: ${token}`);
400
+ }
401
+ return parsed;
402
+ }
403
+ if (command === 'logs') {
404
+ let follow = false;
405
+ let limit = 50;
406
+ let agentId;
407
+ let sessionId;
408
+ let format = 'text';
409
+ for (let index = 0; index < rest.length; index += 1) {
410
+ const token = rest[index];
411
+ if (!token) {
412
+ continue;
413
+ }
414
+ if (token === '--help' || token === '-h') {
415
+ return { command: 'help' };
416
+ }
417
+ if (token === '--follow' || token === '-f') {
418
+ follow = true;
419
+ continue;
420
+ }
421
+ if (token === '--lines' || token === '-n') {
422
+ const value = Number(readOptionValue(rest, index, token));
423
+ if (!Number.isFinite(value) || value < 0) {
424
+ throw new Error(`Invalid value for ${token}: ${rest[index + 1] ?? '(missing)'}`);
425
+ }
426
+ limit = Math.floor(value);
427
+ index += 1;
428
+ continue;
429
+ }
430
+ if (token === '--agent') {
431
+ agentId = readOptionValue(rest, index, '--agent');
432
+ index += 1;
433
+ continue;
434
+ }
435
+ if (token === '--session') {
436
+ sessionId = readOptionValue(rest, index, '--session');
437
+ index += 1;
438
+ continue;
439
+ }
440
+ if (token === '--format') {
441
+ const value = readOptionValue(rest, index, '--format');
442
+ if (value !== 'text' && value !== 'json') {
443
+ throw new Error(`Unsupported format: ${value}`);
444
+ }
445
+ format = value;
446
+ index += 1;
447
+ continue;
448
+ }
449
+ throw new Error(`Unknown option: ${token}`);
450
+ }
451
+ return {
452
+ command: 'logs',
453
+ follow,
454
+ limit,
455
+ format,
456
+ ...(agentId ? { agentId } : {}),
457
+ ...(sessionId ? { sessionId } : {}),
458
+ };
459
+ }
268
460
  if (command === 'agent') {
269
461
  const [subcommand, ...agentRest] = rest;
270
462
  if (subcommand === '--help' || subcommand === '-h') {
@@ -472,9 +664,68 @@ export const formatEvent = (event) => {
472
664
  return null;
473
665
  }
474
666
  };
667
+ /**
668
+ * The fields worth keeping per event type. Tool inputs and message text
669
+ * are deliberately excluded: the log is a trace of what happened, not a
670
+ * second copy of the transcript.
671
+ */
672
+ const eventDetail = (event) => {
673
+ switch (event.type) {
674
+ case 'session.updated':
675
+ return { status: event.status };
676
+ case 'provider.response':
677
+ return { parts: event.parts.length };
678
+ case 'tool.called':
679
+ case 'tool.denied':
680
+ return { tool: event.call.toolName };
681
+ case 'tool.completed':
682
+ return { tool: event.result.toolName, ok: event.result.ok };
683
+ case 'session.failed':
684
+ return { error: event.error };
685
+ default:
686
+ return undefined;
687
+ }
688
+ };
475
689
  // Kept with its historical CLI signature: a parsed run command is a
476
690
  // RuntimeSelection plus CLI-only fields the resolver ignores.
477
691
  export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
692
+ /**
693
+ * A saved subscription sign-in silently demoted to per-token billing is the
694
+ * one config surprise that costs money — an API key in the environment
695
+ * outranks the stored credential, and the run otherwise looks identical.
696
+ * Detected from the resolved config: an apiKey where the stored credential
697
+ * is a subscription token means the environment won.
698
+ */
699
+ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
700
+ if (runtime.provider === 'demo') {
701
+ return;
702
+ }
703
+ const stored = (await loadCredentials(env)).anthropic;
704
+ if (stored?.type !== 'oauth_token') {
705
+ return;
706
+ }
707
+ // The resolver records the variable that actually won — guessing it here
708
+ // would name the wrong one whenever a custom apiKeyEnv or the legacy
709
+ // STRATUSCLAW_ prefix supplied the key, sending the reader to unset
710
+ // something that was never the cause.
711
+ const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
712
+ // A fallback demoted the same way costs exactly as much, and only bites
713
+ // once the primary is already failing — the worst moment to discover it.
714
+ // The fallback path consults the provider's own conventional variable.
715
+ const processEnv = readProcessEnv(env);
716
+ const fallbackVar = runtime.fallback?.provider === 'anthropic'
717
+ && runtime.fallback.apiKey !== undefined
718
+ && readNonEmptyString(processEnv.ANTHROPIC_API_KEY) === runtime.fallback.apiKey
719
+ ? 'ANTHROPIC_API_KEY'
720
+ : undefined;
721
+ const culprit = primary ?? fallbackVar;
722
+ if (!culprit) {
723
+ return;
724
+ }
725
+ writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the Claude subscription sign-in saved by \`stratus setup\`, `
726
+ + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
727
+ + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
728
+ };
478
729
  const createApprovalPolicy = (mode, streams, env, ask) => {
479
730
  if (mode === 'always') {
480
731
  return new AllowAllApprovalPolicy();
@@ -636,6 +887,7 @@ export const runChat = async (command, streams, env = {}) => {
636
887
  ...(command.soul ? { soul: command.soul } : {}),
637
888
  ...(command.configPath ? { configPath: command.configPath } : {}),
638
889
  }, env);
890
+ await warnOnCredentialOverride(runtime, streams, env);
639
891
  // Interactive means the real terminal: an injected stdinStream is by
640
892
  // definition not a TTY conversation, so it never gets prompts, ANSI
641
893
  // styling, or a terminal readline wired to a stream that can't take it.
@@ -1357,6 +1609,94 @@ const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
1357
1609
  return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
1358
1610
  }
1359
1611
  };
1612
+ // Slack verification talks to the Web API over plain fetch rather than
1613
+ // through @stratusagent/channel-slack. Setup must stay usable before that
1614
+ // optional package is installed, and the CLI deliberately does not depend
1615
+ // on it (or on the ~9 MB of Slack SDKs underneath).
1616
+ const SLACK_API_ROOT = 'https://slack.com/api';
1617
+ const callSlack = async (method, token, fetchImpl) => {
1618
+ if (typeof fetchImpl !== 'function') {
1619
+ return { ok: false, verdict: { status: 'unreachable', detail: 'fetch is unavailable' } };
1620
+ }
1621
+ try {
1622
+ const response = await fetchImpl(`${SLACK_API_ROOT}/${method}`, {
1623
+ method: 'POST',
1624
+ headers: {
1625
+ authorization: `Bearer ${token}`,
1626
+ 'content-type': 'application/x-www-form-urlencoded',
1627
+ },
1628
+ });
1629
+ if (!response.ok) {
1630
+ return { ok: false, verdict: { status: 'unreachable', detail: `HTTP ${response.status}` } };
1631
+ }
1632
+ const body = await response.json();
1633
+ if (body.ok === true) {
1634
+ return { ok: true, body };
1635
+ }
1636
+ // Slack answers 200 with { ok: false, error } for bad credentials, so
1637
+ // the error string — not the status — is what condemns a token.
1638
+ return { ok: false, verdict: { status: 'rejected', detail: String(body.error ?? 'unknown error') } };
1639
+ }
1640
+ catch (error) {
1641
+ return { ok: false, verdict: { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) } };
1642
+ }
1643
+ };
1644
+ /** Verifies a bot token and reports the identity it belongs to. */
1645
+ const verifySlackBotToken = async (token, fetchImpl) => {
1646
+ const result = await callSlack('auth.test', token, fetchImpl);
1647
+ if (!result.ok) {
1648
+ return result.verdict;
1649
+ }
1650
+ return {
1651
+ status: 'ok',
1652
+ identity: {
1653
+ ...(typeof result.body.user_id === 'string' ? { botUserId: result.body.user_id } : {}),
1654
+ ...(typeof result.body.team === 'string' ? { teamName: result.body.team } : {}),
1655
+ ...(typeof result.body.team_id === 'string' ? { teamId: result.body.team_id } : {}),
1656
+ },
1657
+ };
1658
+ };
1659
+ /**
1660
+ * Verifies an app-level token by opening a Socket Mode URL. The URL is
1661
+ * discarded — this only proves the token carries connections:write, which
1662
+ * is the failure the daemon would otherwise hit at start time.
1663
+ */
1664
+ const verifySlackAppToken = async (token, fetchImpl) => {
1665
+ const result = await callSlack('apps.connections.open', token, fetchImpl);
1666
+ return result.ok ? { status: 'ok', identity: {} } : result.verdict;
1667
+ };
1668
+ // Scopes and events the adapter needs. Kept in step with the manifest
1669
+ // shipped by @stratusagent/channel-slack (a test pins them together) so
1670
+ // setup can hand over a ready-to-paste manifest without depending on that
1671
+ // package being installed.
1672
+ const SLACK_BOT_SCOPES = [
1673
+ 'app_mentions:read',
1674
+ 'chat:write',
1675
+ 'files:write',
1676
+ 'im:history',
1677
+ 'im:read',
1678
+ 'im:write',
1679
+ 'users:read',
1680
+ ];
1681
+ const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
1682
+ export const slackAppManifest = (agentName) => JSON.stringify({
1683
+ display_information: {
1684
+ name: agentName,
1685
+ description: 'A Stratus agent',
1686
+ background_color: '#1a1d21',
1687
+ },
1688
+ features: {
1689
+ bot_user: { display_name: agentName, always_online: true },
1690
+ },
1691
+ oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
1692
+ settings: {
1693
+ event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
1694
+ interactivity: { is_enabled: false },
1695
+ org_deploy_enabled: false,
1696
+ socket_mode_enabled: true,
1697
+ token_rotation_enabled: false,
1698
+ },
1699
+ }, null, 2);
1360
1700
  const DEFAULT_SOUL_STARTER = [
1361
1701
  'You are a helpful, warm generalist. Answer first, explain second, and',
1362
1702
  'keep replies short unless the question genuinely needs depth. Use',
@@ -1394,6 +1734,12 @@ export const runSetup = async (command, streams, env = {}) => {
1394
1734
  // A path passed via --config is not auto-discovered by `stratus run`, so
1395
1735
  // suggested commands must carry it explicitly.
1396
1736
  const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
1737
+ // Set once save() detects a project config shadowing the global one.
1738
+ // Suggested commands must carry it too, or they read a different config
1739
+ // than the one just written — for `serve` that means a different roster,
1740
+ // and Slack apps stored here would be skipped as having no agent.
1741
+ let shadowConfigFlag = '';
1742
+ const serveCommand = () => `stratus serve${runConfigFlag}${shadowConfigFlag}`;
1397
1743
  // Seed from what is already configured, so re-running setup edits instead
1398
1744
  // of clobbering.
1399
1745
  let existing = {};
@@ -1446,6 +1792,24 @@ export const runSetup = async (command, streams, env = {}) => {
1446
1792
  ...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
1447
1793
  credentials: await loadCredentials(env),
1448
1794
  credentialsDirty: false,
1795
+ channels: await loadChannelCredentials(env),
1796
+ channelsDirty: false,
1797
+ // On by default: setup's whole promise is that you finish it and the
1798
+ // agents are working. An always-on runtime you have to remember to
1799
+ // start is not always-on, and every Slack app configured here stays
1800
+ // silent until stratusd is up. An existing install keeps ITS login
1801
+ // setting, though — rerunning setup and pressing save must not quietly
1802
+ // undo a deliberate `service install --no-login`.
1803
+ service: await (async () => {
1804
+ const status = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
1805
+ // An unknown answer (a broken user bus, a timed-out query) must not
1806
+ // read as "they chose --no-login" — that would disable a service on
1807
+ // the next save because of a transient failure.
1808
+ return {
1809
+ install: true,
1810
+ runAtLogin: status?.installed && status.runAtLogin !== undefined ? status.runAtLogin : true,
1811
+ };
1812
+ })(),
1449
1813
  };
1450
1814
  const prompter = createSetupPrompter(baseStreams, env, {
1451
1815
  header: stratusHeaderLines,
@@ -1512,6 +1876,13 @@ export const runSetup = async (command, streams, env = {}) => {
1512
1876
  }
1513
1877
  return state.soulPath;
1514
1878
  };
1879
+ const channelsSummary = () => {
1880
+ const connected = Object.keys(state.channels.slack ?? {}).length;
1881
+ if (connected === 0) {
1882
+ return 'Slack: not connected';
1883
+ }
1884
+ return `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
1885
+ };
1515
1886
  const storeCredential = (provider, credential) => {
1516
1887
  state.credentials[provider] = credential;
1517
1888
  state.credentialsDirty = true;
@@ -2043,6 +2414,255 @@ export const runSetup = async (command, streams, env = {}) => {
2043
2414
  ...(fallback ? { fallback } : {}),
2044
2415
  };
2045
2416
  };
2417
+ /**
2418
+ * Walks an agent through connecting their own Slack app: show the
2419
+ * manifest to paste, take both tokens, verify each against Slack, and
2420
+ * store them under the agent's id. Tokens are keyed by a roster agent
2421
+ * picked from a list rather than typed, so the id can never drift from
2422
+ * the roster — the mismatch that otherwise surfaces only as a startup
2423
+ * warning about an agent the gateway cannot find.
2424
+ */
2425
+ const connectSlackAgent = async (agentId, agentName) => {
2426
+ writeLine(streams.stdout);
2427
+ writeLine(streams.stdout, `Create a Slack app for ${agentName}:`);
2428
+ writeLine(streams.stdout, ' 1. Open https://api.slack.com/apps → Create New App → From a manifest');
2429
+ writeLine(streams.stdout, ' 2. Pick your workspace, then paste this manifest:');
2430
+ writeLine(streams.stdout);
2431
+ for (const line of slackAppManifest(agentName).split('\n')) {
2432
+ writeLine(streams.stdout, ` ${line}`);
2433
+ }
2434
+ writeLine(streams.stdout);
2435
+ writeLine(streams.stdout, ' 3. Create the app, then Basic Information → App-Level Tokens →');
2436
+ writeLine(streams.stdout, ' Generate a token with the connections:write scope (xapp-…)');
2437
+ writeLine(streams.stdout, ' 4. Install App → copy the Bot User OAuth Token (xoxb-…)');
2438
+ writeLine(streams.stdout, ` 5. Basic Information → Display Information → upload ${agentName}'s avatar`);
2439
+ writeLine(streams.stdout);
2440
+ const appToken = await prompter.askSecret('Paste the app-level token (xapp-…, Enter to cancel; input is hidden): ');
2441
+ if (!appToken) {
2442
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
2443
+ return;
2444
+ }
2445
+ if (!appToken.startsWith('xapp-')) {
2446
+ writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-). Nothing was saved.');
2447
+ return;
2448
+ }
2449
+ const botToken = await prompter.askSecret('Paste the bot user OAuth token (xoxb-…, Enter to cancel; input is hidden): ');
2450
+ if (!botToken) {
2451
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
2452
+ return;
2453
+ }
2454
+ if (!botToken.startsWith('xoxb-')) {
2455
+ writeLine(streams.stdout, '✗ That does not look like a bot token (they start with xoxb-). Nothing was saved.');
2456
+ return;
2457
+ }
2458
+ writeLine(streams.stdout, 'Checking the tokens with Slack…');
2459
+ const fetchImpl = env.fetch ?? globalThis.fetch;
2460
+ const bot = await verifySlackBotToken(botToken, fetchImpl);
2461
+ if (bot.status === 'rejected') {
2462
+ writeLine(streams.stdout, `✗ Slack rejected the bot token (${bot.detail}). Nothing was saved — reinstall the app and copy the token again.`);
2463
+ return;
2464
+ }
2465
+ const app = await verifySlackAppToken(appToken, fetchImpl);
2466
+ if (app.status === 'rejected') {
2467
+ writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — check it has the connections:write scope.`);
2468
+ return;
2469
+ }
2470
+ const slack = { ...(state.channels.slack ?? {}) };
2471
+ slack[agentId] = { appToken, botToken };
2472
+ state.channels = { ...state.channels, slack };
2473
+ state.channelsDirty = true;
2474
+ if (bot.status === 'ok' && app.status === 'ok') {
2475
+ const where = bot.identity.teamName ? ` in ${bot.identity.teamName}` : '';
2476
+ const who = bot.identity.botUserId ? ` (bot ${bot.identity.botUserId})` : '';
2477
+ writeLine(streams.stdout, `✓ Verified — ${agentName} is connected to Slack${where}${who}.`);
2478
+ }
2479
+ else {
2480
+ // Unreachable is not a verdict on the tokens: save and let the
2481
+ // daemon report on its first connection attempt.
2482
+ const detail = bot.status === 'unreachable' ? bot.detail : app.detail;
2483
+ writeLine(streams.stdout, `! Could not reach Slack to verify (${detail}). Saved the tokens anyway — \`${serveCommand()}\` will report on startup.`);
2484
+ }
2485
+ };
2486
+ /**
2487
+ * Every agent the gateway would dispatch to: the built-in Stratus
2488
+ * agent, the ~/.stratus/agents roster, and the configured default soul,
2489
+ * which `stratus setup` can point at a file anywhere. The gateway
2490
+ * registers all three, so a Channels list built from the agents
2491
+ * directory alone would hide agents Slack can perfectly well talk to.
2492
+ * Reads state.soulPath rather than the saved config so a soul chosen
2493
+ * earlier in this same setup session is already connectable.
2494
+ */
2495
+ const channelRoster = async () => {
2496
+ const warnOnce = (message) => writeLine(streams.stderr, `Warning: ${message}.`);
2497
+ // Seeded before the roster loads, exactly as loadRoster does it: a
2498
+ // fresh install with no soul files still has an agent to put on
2499
+ // Slack, and a roster file that declares id "stratus" cannot take the
2500
+ // built-in's place — the gateway skips it, so offering it here would
2501
+ // name an app after an agent that never receives the messages.
2502
+ 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);
2515
+ }
2516
+ // The soul a run resolves to, in resolveSoulPath's own order: an env
2517
+ // override outranks the config value this setup session is editing.
2518
+ // Listing the config soul while `stratus serve` registers the env one
2519
+ // would store tokens against an id the adapter then skips.
2520
+ const processEnv = readProcessEnv(env);
2521
+ const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
2522
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
2523
+ if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
2524
+ warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
2525
+ }
2526
+ const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
2527
+ if (!effectiveSoul) {
2528
+ return entries;
2529
+ }
2530
+ const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
2531
+ if (entries.some((entry) => entry.path === resolved)) {
2532
+ return entries;
2533
+ }
2534
+ try {
2535
+ const soul = await loadSoulFile(resolved);
2536
+ // An explicit id can collide with a roster file, or with the
2537
+ // built-in. The gateway's defaultAgentId replaces the registered
2538
+ // source whenever it is pathless (the built-in) or resolves to a
2539
+ // different file, so the configured soul is the one Slack actually
2540
+ // dispatches to — offering the namesake here would connect an app
2541
+ // to a different agent than it names.
2542
+ const collision = entries.findIndex((entry) => entry.soul.agent.id === soul.agent.id);
2543
+ if (collision >= 0) {
2544
+ entries.splice(collision, 1);
2545
+ }
2546
+ entries.unshift({ soul, path: resolved });
2547
+ }
2548
+ catch (error) {
2549
+ warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
2550
+ }
2551
+ return entries;
2552
+ };
2553
+ const serviceSummary = () => {
2554
+ if (!servicePlatform(serviceEnvFor(env))) {
2555
+ return `not available on ${env.processEnv?.OSTYPE ?? process.platform} — run \`stratus serve\` yourself`;
2556
+ }
2557
+ if (!state.service.install) {
2558
+ return 'off — start stratusd yourself with `stratus serve`';
2559
+ }
2560
+ return state.service.runAtLogin
2561
+ ? 'stratusd runs after setup, and at every login'
2562
+ : 'stratusd runs after setup, but not at login';
2563
+ };
2564
+ /**
2565
+ * Whether the roster keeps answering once this terminal closes. On by
2566
+ * default: an agent you have to remember to start is not always-on, and
2567
+ * every Slack app configured above is silent until stratusd runs.
2568
+ */
2569
+ const chooseService = async () => {
2570
+ if (!servicePlatform(serviceEnvFor(env))) {
2571
+ writeLine(streams.stdout);
2572
+ writeLine(streams.stdout, `Stratus has no service integration for ${process.platform} yet — run \`stratus serve\` yourself, or supervise it however you prefer.`);
2573
+ await prompter.ask('Press Enter to return to the menu… ');
2574
+ return;
2575
+ }
2576
+ const choice = await prompter.select('Always on — keep stratusd running in the background', [
2577
+ 'Run after setup, and start again at every login (recommended)',
2578
+ 'Run after setup, but do not start at login',
2579
+ 'Do not run it for me — I will start `stratus serve` myself',
2580
+ 'Back',
2581
+ ], {
2582
+ footnote: process.platform === 'darwin'
2583
+ // Said here rather than left to be discovered after a reboot.
2584
+ ? 'A LaunchAgent starts at login, not at power-on. For a machine that should recover unattended, turn on automatic login too.'
2585
+ : 'A user service starts at login. `loginctl enable-linger` keeps it up on a machine you do not stay logged in to.',
2586
+ });
2587
+ if (choice.kind !== 'index' || choice.index === 3) {
2588
+ return;
2589
+ }
2590
+ state.service = {
2591
+ install: choice.index !== 2,
2592
+ runAtLogin: choice.index === 0,
2593
+ };
2594
+ };
2595
+ const chooseChannels = async () => {
2596
+ while (true) {
2597
+ const roster = await channelRoster();
2598
+ const slack = state.channels.slack ?? {};
2599
+ // The roster always holds at least the built-in agent, so there is
2600
+ // no empty state to short-circuit on — and short-circuiting would
2601
+ // strand orphaned tokens, which are only reachable from this list.
2602
+ const options = roster.map((entry) => {
2603
+ const id = entry.soul.agent.id;
2604
+ const status = slack[id] ? '✓ connected' : '— not connected';
2605
+ return `${entry.soul.agent.name} (${id})`.padEnd(34) + status;
2606
+ });
2607
+ // Orphans: tokens whose agent left the roster would otherwise be
2608
+ // 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));
2610
+ for (const id of orphans) {
2611
+ options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
2612
+ }
2613
+ options.push('Back');
2614
+ const choice = await prompter.select('Channels — Slack (one app per agent: its own name, avatar, and presence)', options, { footnote: `Run \`${serveCommand()}\` afterwards to bring the connected agents online.` });
2615
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
2616
+ return;
2617
+ }
2618
+ if (choice.index >= roster.length) {
2619
+ // An orphaned entry — offer to clear it.
2620
+ const orphanId = orphans[choice.index - roster.length];
2621
+ if (!orphanId) {
2622
+ return;
2623
+ }
2624
+ const confirm = await prompter.select(`No agent with id ${orphanId} is in the roster.`, [
2625
+ 'Remove these Slack tokens',
2626
+ 'Keep them',
2627
+ ]);
2628
+ if (confirm.kind === 'index' && confirm.index === 0) {
2629
+ const next = { ...slack };
2630
+ delete next[orphanId];
2631
+ state.channels = { ...state.channels, slack: next };
2632
+ state.channelsDirty = true;
2633
+ writeLine(streams.stdout, `Removed the Slack tokens for ${orphanId}.`);
2634
+ }
2635
+ continue;
2636
+ }
2637
+ const entry = roster[choice.index];
2638
+ if (!entry) {
2639
+ return;
2640
+ }
2641
+ const agentId = entry.soul.agent.id;
2642
+ const agentName = entry.soul.agent.name;
2643
+ if (!slack[agentId]) {
2644
+ await connectSlackAgent(agentId, agentName);
2645
+ continue;
2646
+ }
2647
+ const action = await prompter.select(`${agentName} is connected to Slack.`, [
2648
+ 'Replace the tokens (re-run the app setup)',
2649
+ 'Disconnect from Slack (forget the tokens)',
2650
+ 'Back',
2651
+ ]);
2652
+ if (action.kind !== 'index' || action.index === 2) {
2653
+ continue;
2654
+ }
2655
+ if (action.index === 0) {
2656
+ await connectSlackAgent(agentId, agentName);
2657
+ continue;
2658
+ }
2659
+ const next = { ...slack };
2660
+ delete next[agentId];
2661
+ state.channels = { ...state.channels, slack: next };
2662
+ state.channelsDirty = true;
2663
+ writeLine(streams.stdout, `${agentName} is no longer connected to Slack. The Slack app itself still exists — delete it at api.slack.com/apps if you are done with it.`);
2664
+ }
2665
+ };
2046
2666
  const testRun = async () => {
2047
2667
  const runtime = await buildTestRuntime();
2048
2668
  if (!runtime) {
@@ -2092,7 +2712,6 @@ export const runSetup = async (command, streams, env = {}) => {
2092
2712
  };
2093
2713
  };
2094
2714
  const save = async () => {
2095
- let shadowConfigFlag = '';
2096
2715
  const config = { provider: state.provider };
2097
2716
  if (state.provider !== 'demo') {
2098
2717
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -2149,6 +2768,66 @@ export const runSetup = async (command, streams, env = {}) => {
2149
2768
  await saveCredentials(env, state.credentials);
2150
2769
  writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
2151
2770
  }
2771
+ if (state.channelsDirty) {
2772
+ // Channel tokens live in their own namespace of the same file; the
2773
+ // writers merge, so this never clobbers the provider sign-in above.
2774
+ await saveChannelCredentials(env, state.channels);
2775
+ const connected = Object.keys(state.channels.slack ?? {}).length;
2776
+ writeLine(streams.stdout, connected > 0
2777
+ ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2778
+ : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2779
+ }
2780
+ // Last, so the daemon starts against the config and credentials that
2781
+ // were just written rather than the ones it would have found a moment
2782
+ // ago. A service failure is reported and never fails setup: the
2783
+ // settings are already saved, and `stratus serve` still works by hand.
2784
+ if (!state.service.install && servicePlatform(serviceEnvFor(env))) {
2785
+ // Opting out has to actually take effect. Skipping the install would
2786
+ // leave a unit from an earlier setup running and enabled at login,
2787
+ // while the menu said "off" — still burning provider usage and still
2788
+ // answering in Slack after an explicit opt-out.
2789
+ const existing = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
2790
+ if (existing?.installed) {
2791
+ // Removal deletes the unit file, so it can reject the same way the
2792
+ // install can. Setup's settings are already written by this point;
2793
+ // the optional service must not take the whole command down.
2794
+ const removed = await uninstallService(serviceEnvFor(env)).catch((error) => ({
2795
+ ok: false,
2796
+ messages: [
2797
+ `Could not remove the always-on service: ${error instanceof Error ? error.message : String(error)}`,
2798
+ `${serviceUnitPath(serviceEnvFor(env))} is still in place — remove it by hand, or it will start again at login.`,
2799
+ ],
2800
+ }));
2801
+ for (const message of removed.messages) {
2802
+ writeLine(removed.ok ? streams.stdout : streams.stderr, message);
2803
+ }
2804
+ }
2805
+ }
2806
+ else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
2807
+ // The unit is pinned to the file setup just wrote. Its working
2808
+ // directory is the home directory, so discovery from there would
2809
+ // find a different config whenever setup was run with --config or
2810
+ // STRATUS_CONFIG — the daemon would come up on another roster and
2811
+ // leave the Slack apps configured above offline.
2812
+ // installService writes files, so it can reject outright — an
2813
+ // inaccessible ~/Library/LaunchAgents, a read-only home. Letting
2814
+ // that escape would fail setup itself, after the config and
2815
+ // credentials were already saved, when the always-on service is the
2816
+ // one optional part of it.
2817
+ const result = await installService(serviceEnvFor(env), {
2818
+ runAtLogin: state.service.runAtLogin,
2819
+ configPath,
2820
+ }).catch((error) => ({
2821
+ ok: false,
2822
+ messages: [`Could not install the always-on service: ${error instanceof Error ? error.message : String(error)}`],
2823
+ }));
2824
+ for (const message of result.messages) {
2825
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
2826
+ }
2827
+ if (!result.ok) {
2828
+ writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
2829
+ }
2830
+ }
2152
2831
  writeLine(streams.stdout);
2153
2832
  // Exported STRATUS_* variables outrank the config file, so warn when one
2154
2833
  // would make `stratus run` behave differently from what was just saved.
@@ -2210,11 +2889,13 @@ export const runSetup = async (command, streams, env = {}) => {
2210
2889
  `Providers ${providersSummary()}`,
2211
2890
  `Models ${modelsSummary()}`,
2212
2891
  `Agent ${agentSummary()}`,
2892
+ `Channels ${channelsSummary()}`,
2893
+ `Always on ${serviceSummary()}`,
2213
2894
  'Test run say hello with the current settings',
2214
2895
  'Save & finish',
2215
2896
  ]);
2216
2897
  // Backing out of the top level (Esc, or the input ending) saves.
2217
- if (choice.kind !== 'index' || choice.index === 4) {
2898
+ if (choice.kind !== 'index' || choice.index === 6) {
2218
2899
  break;
2219
2900
  }
2220
2901
  if (choice.index === 0) {
@@ -2227,6 +2908,12 @@ export const runSetup = async (command, streams, env = {}) => {
2227
2908
  await chooseAgent();
2228
2909
  }
2229
2910
  else if (choice.index === 3) {
2911
+ await chooseChannels();
2912
+ }
2913
+ else if (choice.index === 4) {
2914
+ await chooseService();
2915
+ }
2916
+ else if (choice.index === 5) {
2230
2917
  await testRun();
2231
2918
  }
2232
2919
  }
@@ -2381,6 +3068,519 @@ export const runAgents = async (command, streams, env = {}) => {
2381
3068
  : 'Talk to the default with stratus run, or to anyone with stratus run --soul <file>.');
2382
3069
  return 0;
2383
3070
  };
3071
+ /**
3072
+ * `stratus doctor` — what a run would resolve to right now, and which file
3073
+ * or variable decided each part. Every "why is it using X" question needs
3074
+ * the precedence chain, and reading it out of the source is not something
3075
+ * anyone should have to do.
3076
+ */
3077
+ export const collectDoctorReport = async (command, warn, env = {}) => {
3078
+ const processEnv = readProcessEnv(env);
3079
+ const cwd = readWorkingDirectory(env);
3080
+ const problems = [];
3081
+ // Config discovery, spelled out rather than delegated: doctor has to
3082
+ // report the files that LOST as well as the one in use, which is the
3083
+ // whole point when a stray project config is the answer.
3084
+ // Which of the three named it, not just that one did: telling someone to
3085
+ // fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
3086
+ // real override in place — the same mistake as provider and key
3087
+ // attribution, in the one place a typo is most likely.
3088
+ const explicitSource = command.configPath !== undefined
3089
+ ? { name: '--config', value: command.configPath }
3090
+ : readNonEmptyString(processEnv.STRATUS_CONFIG)
3091
+ ? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
3092
+ : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
3093
+ ? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
3094
+ : undefined;
3095
+ const explicit = explicitSource?.value;
3096
+ const candidates = explicitSource
3097
+ ? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
3098
+ : [
3099
+ { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
3100
+ { path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
3101
+ { path: globalConfigPath(env), label: 'global' },
3102
+ ];
3103
+ const present = [];
3104
+ let unreadable;
3105
+ for (const candidate of candidates) {
3106
+ try {
3107
+ await readFile(candidate.path, 'utf8');
3108
+ present.push(candidate);
3109
+ }
3110
+ catch (error) {
3111
+ const code = error.code;
3112
+ if (code !== 'ENOENT') {
3113
+ // resolveConfigLocation throws here rather than trying the next
3114
+ // candidate, so discovery stops: a lower-priority file that a real
3115
+ // run never reaches must not be reported as the config in use.
3116
+ unreadable = candidate;
3117
+ problems.push(`${candidate.path} exists but cannot be read (${error.message}). Every run fails here — discovery never reaches a lower-priority config.`);
3118
+ break;
3119
+ }
3120
+ if (explicit) {
3121
+ // Discovery treats a missing candidate as "try the next one", but
3122
+ // an explicitly named config has no next one: a real run calls
3123
+ // loadConfigFile on it and fails. Reporting built-in defaults here
3124
+ // would describe a run that cannot happen, and hide the typo.
3125
+ problems.push(`${candidate.path} does not exist, but ${candidate.label} names it — every run with this setting fails. `
3126
+ + 'Fix the path, or drop the setting to fall back to config discovery.');
3127
+ }
3128
+ }
3129
+ }
3130
+ const winner = unreadable ?? present[0];
3131
+ const shadowed = unreadable ? [] : present.slice(1);
3132
+ if (winner && shadowed.length > 0 && winner.label.startsWith('project')) {
3133
+ problems.push(`${winner.path} outranks ${shadowed.map((entry) => entry.path).join(' and ')} for runs started in this directory. `
3134
+ + 'Run from elsewhere, or pass --config to pick one explicitly.');
3135
+ }
3136
+ let fileConfig = {};
3137
+ let configFatal = unreadable !== undefined;
3138
+ if (winner && !unreadable) {
3139
+ try {
3140
+ fileConfig = await loadConfigFile(winner.path);
3141
+ }
3142
+ catch (error) {
3143
+ // resolveRuntimeConfig propagates this — no run reaches provider or
3144
+ // credential resolution, so presenting defaults as resolved settings
3145
+ // would describe a run that cannot happen.
3146
+ configFatal = true;
3147
+ problems.push(`${winner.path} could not be parsed (${error.message}). Every run fails until it is fixed or removed.`);
3148
+ warn(`ignoring unreadable config ${winner.path}`);
3149
+ }
3150
+ }
3151
+ // Which variable supplied a value, not just the value: naming the wrong
3152
+ // one sends the reader to unset something that was never the cause,
3153
+ // leaving the real override in place. The legacy STRATUSCLAW_ prefix is
3154
+ // still honored by the resolver, so it has to be reportable too.
3155
+ const envPick = (...names) => {
3156
+ for (const name of names) {
3157
+ const value = readNonEmptyString(processEnv[name]);
3158
+ if (typeof value === 'string') {
3159
+ return { name, value };
3160
+ }
3161
+ }
3162
+ return undefined;
3163
+ };
3164
+ /**
3165
+ * The verdict comes from the resolver, not from a second copy of its
3166
+ * rules. Everything below only *attributes* values to the file or
3167
+ * variable that supplied them — which the resolver does not report —
3168
+ * while whether a run works at all, and what it bills, is whatever
3169
+ * resolveRuntimeConfig actually returns or throws.
3170
+ */
3171
+ const envSoul = envPick('STRATUS_SOUL', 'STRATUSCLAW_SOUL');
3172
+ const soulValue = envSoul?.value ?? fileConfig.soul;
3173
+ const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
3174
+ const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
3175
+ // Loaded here for ATTRIBUTION only — the soul's frontmatter is what
3176
+ // explains a provider nobody wrote in a config file. Whether a run works
3177
+ // is still the resolver's verdict below.
3178
+ const soul = soulPath ? await loadSoulFile(soulPath).catch(() => undefined) : undefined;
3179
+ let resolved;
3180
+ if (!configFatal) {
3181
+ try {
3182
+ resolved = await resolveStateRuntimeConfig(command.configPath ? { configPath: command.configPath } : {}, env);
3183
+ }
3184
+ catch (error) {
3185
+ const message = error instanceof Error ? error.message : String(error);
3186
+ problems.push(`No run can start: ${message}`);
3187
+ if (soulPath && /soul/i.test(message)) {
3188
+ // Worth saying explicitly: a soul that fails to load is fatal, not
3189
+ // a quiet downgrade to the built-in agent.
3190
+ problems.push(`Every run fails until ${soulPath} is fixed or the soul setting is removed — the built-in agent is not substituted.`);
3191
+ }
3192
+ }
3193
+ }
3194
+ const envProviderPick = envPick('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER');
3195
+ const providerSource = envProviderPick
3196
+ ? envProviderPick.name
3197
+ : soul?.provider
3198
+ ? `${soulPath} (soul frontmatter)`
3199
+ : fileConfig.provider
3200
+ ? (winner?.path ?? 'config')
3201
+ : 'built-in default — nothing set a provider';
3202
+ // When resolution fails the intended provider is still worth reporting:
3203
+ // "why is it anthropic" is exactly the question, and the problems below
3204
+ // already say no run can start.
3205
+ const intendedProvider = envProviderPick?.value ?? soul?.provider ?? fileConfig.provider ?? 'demo';
3206
+ const provider = resolved
3207
+ ? { value: resolved.provider, source: providerSource }
3208
+ : { value: `${intendedProvider} (unreachable)`, source: providerSource };
3209
+ if (provider.value === 'demo') {
3210
+ // Setup writes the config file, which is the LOWEST precedence of the
3211
+ // three — so when an env var or a soul chose demo, "run setup" is
3212
+ // advice that changes nothing and leaves every run on demo.
3213
+ const fix = envProviderPick
3214
+ ? `Unset ${envProviderPick.name}; it outranks both your config and any soul.`
3215
+ : soul?.provider === 'demo'
3216
+ ? `Change or remove the provider pin in ${soulPath}; a soul outranks the config file.`
3217
+ : fileConfig.provider === 'demo'
3218
+ ? `Change "provider" in ${winner?.path}, or run \`stratus setup\` → Providers.`
3219
+ : 'Run `stratus setup` → Providers and sign in, then Save & finish.';
3220
+ problems.push(`Provider is the offline demo model, so replies are canned. ${fix}`);
3221
+ }
3222
+ let model;
3223
+ if (resolved && resolved.provider !== 'demo') {
3224
+ const envModel = envPick('STRATUS_MODEL', 'STRATUSCLAW_MODEL');
3225
+ // A soul's model belongs to the soul's provider, and the config's model
3226
+ // to the config's provider — either can be stranded by an override.
3227
+ const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
3228
+ const configModelApplies = (fileConfig.provider ?? 'openai') === resolved.provider;
3229
+ model = {
3230
+ value: resolved.model,
3231
+ source: envModel
3232
+ ? envModel.name
3233
+ : soulModelApplies && soul?.model === resolved.model
3234
+ ? `${soulPath} (soul frontmatter)`
3235
+ : configModelApplies && fileConfig.model === resolved.model
3236
+ ? (winner?.path ?? 'config')
3237
+ : 'built-in default for this provider',
3238
+ };
3239
+ }
3240
+ const credentials = await loadCredentials(env);
3241
+ const signIns = [];
3242
+ const usedKeyEnvVar = resolved && resolved.provider !== 'demo' ? resolved.apiKeyEnvVar : undefined;
3243
+ const usesAuthToken = resolved?.provider === 'anthropic' && resolved.authToken !== undefined;
3244
+ const fallback = resolved && resolved.provider !== 'demo' ? resolved.fallback : undefined;
3245
+ for (const target of ['anthropic', 'openai']) {
3246
+ const stored = credentials[target];
3247
+ const label = stored?.type === 'oauth_token' ? 'Claude subscription (Pro/Max)' : 'API key';
3248
+ const isDefault = resolved?.provider === target;
3249
+ const isFallback = fallback?.provider === target;
3250
+ // A provider no resolved run consults is reported, never diagnosed:
3251
+ // flagging an override on a credential nothing reads is a false alarm.
3252
+ if (!isDefault && !isFallback) {
3253
+ signIns.push({
3254
+ provider: target,
3255
+ status: stored
3256
+ ? `${label} (unused — ${resolved ? resolved.provider : 'nothing'} serves your runs)`
3257
+ : 'not signed in',
3258
+ });
3259
+ continue;
3260
+ }
3261
+ const envVar = isDefault ? usedKeyEnvVar : (target === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY');
3262
+ const viaEnv = isDefault
3263
+ ? usedKeyEnvVar !== undefined
3264
+ : fallback?.apiKey !== undefined && fallback.apiKey === readNonEmptyString(processEnv[String(envVar)]);
3265
+ const where = isDefault ? 'runs are' : 'fallback runs are';
3266
+ if (stored && viaEnv) {
3267
+ signIns.push({ provider: target, status: `${label}, overridden by ${envVar} in your environment` });
3268
+ // The costly case: an env key silently demotes a subscription to
3269
+ // per-token billing, and nothing in a normal run says so.
3270
+ problems.push(stored.type === 'oauth_token'
3271
+ ? `${envVar} in your environment outranks your saved ${target} subscription sign-in, so ${where} billed per token instead of through your plan. Unset it (check your shell profile) to use the subscription.`
3272
+ : `${envVar} in your environment outranks your saved ${target} sign-in. Unset it to use the one \`stratus setup\` stored.`);
3273
+ continue;
3274
+ }
3275
+ if (viaEnv) {
3276
+ signIns.push({ provider: target, status: `using ${envVar} from your environment` });
3277
+ continue;
3278
+ }
3279
+ if (stored) {
3280
+ signIns.push({ provider: target, status: isDefault && usesAuthToken ? `${label} — runs go through the Claude Code runtime` : label });
3281
+ continue;
3282
+ }
3283
+ signIns.push({ provider: target, status: 'not signed in' });
3284
+ }
3285
+ // A fallback the resolver dropped is the quiet failure: the config still
3286
+ // names one, but a failing primary has nothing to retry on.
3287
+ if (fileConfig.fallbackModel !== undefined && resolved && resolved.provider !== 'demo' && !fallback) {
3288
+ problems.push(`A fallback model (${fileConfig.fallbackModel}) is configured but could not be resolved — usually no sign-in for its provider, or an endpoint its saved key is not sent to. `
3289
+ + 'A failing primary model has nothing to retry on.');
3290
+ }
3291
+ const channels = await loadChannelCredentials(env);
3292
+ const slackAgents = Object.keys(channels.slack ?? {}).sort();
3293
+ const slackPackageInstalled = (await loadSlackAdapter()) !== undefined;
3294
+ if (slackAgents.length > 0 && !slackPackageInstalled) {
3295
+ problems.push(`Slack tokens are stored for ${slackAgents.length} agent(s) but @stratusagent/channel-slack is not installed, so \`stratus serve\` skips them. `
3296
+ + 'Install it with: npm install -g @stratusagent/channel-slack');
3297
+ }
3298
+ let rosterCount = 0;
3299
+ try {
3300
+ rosterCount = (await readdir(agentsDirPath(env))).filter((file) => file.endsWith('.md')).length;
3301
+ }
3302
+ catch (error) {
3303
+ if (error.code !== 'ENOENT') {
3304
+ throw error;
3305
+ }
3306
+ }
3307
+ // Tokens keyed to an agent the roster no longer has are loaded and
3308
+ // skipped on every start, silently.
3309
+ const rosterIds = new Set((await loadRosterSouls(env, warn)).map((entry) => entry.soul.agent.id));
3310
+ rosterIds.add(DEFAULT_STRATUS_AGENT.id);
3311
+ if (soul) {
3312
+ rosterIds.add(soul.agent.id);
3313
+ }
3314
+ const orphans = slackAgents.filter((id) => !rosterIds.has(id));
3315
+ if (orphans.length > 0) {
3316
+ problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
3317
+ + 'Clear them from `stratus setup` → Channels.');
3318
+ }
3319
+ return {
3320
+ ...(winner ? { configInUse: winner.path } : {}),
3321
+ configShadowed: shadowed.map((entry) => entry.path),
3322
+ provider,
3323
+ ...(model ? { model } : {}),
3324
+ ...(soulPath ? { soul: { value: soulPath, source: soulSource } } : {}),
3325
+ ...(soul ? { agent: `${soul.agent.name} (${soul.agent.id})` } : {}),
3326
+ ...(fallback ? { fallback: { provider: fallback.provider, model: fallback.model } } : {}),
3327
+ signIns,
3328
+ slackAgents,
3329
+ slackPackageInstalled,
3330
+ rosterCount,
3331
+ problems,
3332
+ };
3333
+ };
3334
+ export const runDoctor = async (command, streams, env = {}) => {
3335
+ const report = await collectDoctorReport(command, (message) => {
3336
+ writeLine(streams.stderr, `Warning: ${message}.`);
3337
+ }, env);
3338
+ if (command.format === 'json') {
3339
+ writeLine(streams.stdout, JSON.stringify(report, null, 2));
3340
+ return report.problems.length > 0 ? 1 : 0;
3341
+ }
3342
+ const field = (label, setting) => {
3343
+ if (!setting) {
3344
+ return;
3345
+ }
3346
+ writeLine(streams.stdout, ` ${label.padEnd(10)}${setting.value}`);
3347
+ writeLine(streams.stdout, ` ${''.padEnd(10)}from ${setting.source}`);
3348
+ };
3349
+ writeLine(streams.stdout, 'Stratus Agent — what a run would use right now');
3350
+ writeLine(streams.stdout);
3351
+ field('provider', report.provider);
3352
+ field('model', report.model);
3353
+ field('soul', report.soul);
3354
+ if (report.agent) {
3355
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${report.agent}`);
3356
+ }
3357
+ else if (report.soul) {
3358
+ // A soul is configured but did not load — saying "built-in" here would
3359
+ // describe a run that cannot happen.
3360
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}unresolved — the configured soul could not be read`);
3361
+ }
3362
+ else {
3363
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${DEFAULT_STRATUS_AGENT.name} (built-in — no soul configured)`);
3364
+ }
3365
+ writeLine(streams.stdout);
3366
+ writeLine(streams.stdout, 'Files');
3367
+ writeLine(streams.stdout, ` config ${report.configInUse ?? 'none found — built-in defaults apply'}`);
3368
+ for (const shadowedPath of report.configShadowed) {
3369
+ writeLine(streams.stdout, ` (${shadowedPath} exists but is outranked)`);
3370
+ }
3371
+ writeLine(streams.stdout, ` agents ${report.rosterCount} soul file${report.rosterCount === 1 ? '' : 's'}`);
3372
+ if (report.fallback) {
3373
+ writeLine(streams.stdout, ` ${'fallback'.padEnd(10)}${report.fallback.provider} · ${report.fallback.model}`);
3374
+ writeLine(streams.stdout, ` ${''.padEnd(10)}used when the default model errors mid-run`);
3375
+ }
3376
+ writeLine(streams.stdout);
3377
+ writeLine(streams.stdout, 'Sign-ins');
3378
+ for (const entry of report.signIns) {
3379
+ writeLine(streams.stdout, ` ${entry.provider.padEnd(10)}${entry.status}`);
3380
+ }
3381
+ writeLine(streams.stdout);
3382
+ writeLine(streams.stdout, 'Channels');
3383
+ writeLine(streams.stdout, ` slack ${report.slackAgents.length === 0
3384
+ ? 'no agents connected'
3385
+ : `${report.slackAgents.length} connected (${report.slackAgents.join(', ')})`}`);
3386
+ writeLine(streams.stdout, ` @stratusagent/channel-slack ${report.slackPackageInstalled ? 'installed' : 'not installed'}`);
3387
+ writeLine(streams.stdout);
3388
+ if (report.problems.length === 0) {
3389
+ writeLine(streams.stdout, 'No problems found.');
3390
+ return 0;
3391
+ }
3392
+ writeLine(streams.stdout, `${report.problems.length} problem${report.problems.length === 1 ? '' : 's'} found:`);
3393
+ for (const problem of report.problems) {
3394
+ writeLine(streams.stdout, ` ! ${problem}`);
3395
+ }
3396
+ return 1;
3397
+ };
3398
+ /**
3399
+ * `stratus logs` — the daemon's structured log, filtered. `serve` streams
3400
+ * to its own stdout, which is gone the moment it runs under a service
3401
+ * manager; this reads the file it also writes, from any terminal.
3402
+ */
3403
+ export const runLogs = async (command, streams, env = {}) => {
3404
+ const dir = logsDirPath(env);
3405
+ const filter = {
3406
+ ...(command.agentId ? { agentId: command.agentId } : {}),
3407
+ ...(command.sessionId ? { sessionId: command.sessionId } : {}),
3408
+ };
3409
+ const emit = (record) => {
3410
+ writeLine(streams.stdout, command.format === 'json' ? JSON.stringify(record) : formatLogRecord(record));
3411
+ };
3412
+ // Captured BEFORE the backlog is read: the daemon keeps writing while a
3413
+ // large backlog prints, and a follower that takes its offset afterwards
3414
+ // skips everything written in between — permanently.
3415
+ const followFrom = command.follow ? await currentLogPosition(dir) : undefined;
3416
+ // Bounded by the same offset the follower resumes from: without it, a
3417
+ // record written between the two reads is printed by the backlog and
3418
+ // then again by the stream.
3419
+ const recent = await readRecentRecords(dir, command.limit, filter, followFrom);
3420
+ for (const record of recent) {
3421
+ emit(record);
3422
+ }
3423
+ if (!command.follow) {
3424
+ if (recent.length === 0 && command.format === 'text') {
3425
+ writeLine(streams.stdout, `No log records yet in ${dir}. Start the daemon with \`stratus serve\`.`);
3426
+ }
3427
+ return 0;
3428
+ }
3429
+ const controller = new AbortController();
3430
+ const stop = () => controller.abort();
3431
+ process.once('SIGINT', stop);
3432
+ process.once('SIGTERM', stop);
3433
+ env.shutdownSignal?.addEventListener('abort', stop, { once: true });
3434
+ if (env.shutdownSignal?.aborted) {
3435
+ stop();
3436
+ }
3437
+ try {
3438
+ await tailLog({
3439
+ dir,
3440
+ filter,
3441
+ ...(followFrom !== undefined ? { startPosition: followFrom } : {}),
3442
+ signal: controller.signal,
3443
+ onRecord: emit,
3444
+ });
3445
+ }
3446
+ finally {
3447
+ process.off('SIGINT', stop);
3448
+ process.off('SIGTERM', stop);
3449
+ }
3450
+ return 0;
3451
+ };
3452
+ /** Builds the service view of the CLI environment (home, exec paths, runner). */
3453
+ const serviceEnvFor = (env) => ({
3454
+ ...(env.homeDir !== undefined ? { homeDir: env.homeDir } : {}),
3455
+ cwd: readWorkingDirectory(env),
3456
+ ...(env.serviceRunner !== undefined ? { run: env.serviceRunner } : {}),
3457
+ });
3458
+ /**
3459
+ * A credential that exists only in this shell cannot reach the daemon: a
3460
+ * service manager starts with its own environment and never sources a
3461
+ * profile, so the unit would come up unauthenticated while setup had just
3462
+ * reported everything ready. Returns the variable to name, if so.
3463
+ */
3464
+ /**
3465
+ * Every runtime the daemon would resolve: the config-wide default plus one
3466
+ * per roster soul, normalized the way a dispatch normalizes it. A soul that
3467
+ * pins its own provider resolves to different credentials entirely, so any
3468
+ * check that looks only at the default misses exactly the agent that is
3469
+ * misconfigured.
3470
+ */
3471
+ const servedRuntimes = async (env, configPath) => {
3472
+ const { applySoulPins } = await import('@stratusagent/gateway');
3473
+ const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
3474
+ const context = {
3475
+ ...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
3476
+ configPresent: location !== undefined,
3477
+ };
3478
+ const passes = [{ selection: {}, env }];
3479
+ for (const entry of await loadRosterSouls(env, () => { })) {
3480
+ const normalized = applySoulPins(entry.soul, {}, env, context);
3481
+ passes.push({ selection: normalized.selection, env: normalized.env });
3482
+ }
3483
+ const resolved = [];
3484
+ for (const pass of passes) {
3485
+ // A runtime that cannot resolve is the gateway's to report, per
3486
+ // dispatch and with far better context than a startup pass has.
3487
+ const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
3488
+ if (runtime) {
3489
+ resolved.push({ runtime, env: pass.env });
3490
+ }
3491
+ }
3492
+ return resolved;
3493
+ };
3494
+ /**
3495
+ * `stratus service` — run the daemon under launchd or systemd, so it
3496
+ * survives logout, crashes, and reboots. `serve` itself stays a plain
3497
+ * foreground process; this only tells the platform how to keep it up.
3498
+ */
3499
+ export const runService = async (command, streams, env = {}) => {
3500
+ const serviceEnv = serviceEnvFor(env);
3501
+ if (command.action === 'status') {
3502
+ const status = await readServiceStatus(serviceEnv);
3503
+ if (!status) {
3504
+ writeLine(streams.stdout, `No service manager for ${process.platform}. Run \`stratus serve\` yourself.`);
3505
+ return 1;
3506
+ }
3507
+ writeLine(streams.stdout, `stratusd ${status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3508
+ writeLine(streams.stdout, ` manager ${status.platform}`);
3509
+ writeLine(streams.stdout, ` unit ${status.unitPath}`);
3510
+ if (status.detail) {
3511
+ writeLine(streams.stdout, ` note ${status.detail}`);
3512
+ }
3513
+ if (status.installed) {
3514
+ writeLine(streams.stdout, ` at login ${status.runAtLogin === undefined
3515
+ ? 'unknown — the service manager did not answer'
3516
+ : status.runAtLogin ? 'yes' : 'no'}`);
3517
+ }
3518
+ writeLine(streams.stdout, status.installed
3519
+ ? ' logs stratus logs -f'
3520
+ : ' install stratus service install');
3521
+ return status.running ? 0 : 1;
3522
+ }
3523
+ // A service manager passes none of this shell's environment on, so a
3524
+ // config selected by STRATUS_CONFIG has to be baked into the unit
3525
+ // exactly as --config is. Without it the daemon rediscovers from the
3526
+ // install directory and can come up on a different roster entirely.
3527
+ const processEnv = readProcessEnv(env);
3528
+ const selectedConfig = command.configPath
3529
+ ?? readNonEmptyString(processEnv.STRATUS_CONFIG)
3530
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
3531
+ if (command.action === 'install') {
3532
+ // A config the daemon cannot parse kills it during gateway.start(),
3533
+ // and the manager — having accepted the start — restarts it on a
3534
+ // loop. Better to refuse now, while there is someone reading stderr.
3535
+ // Without an explicit selection the unit carries no --config flag and
3536
+ // discovers from its working directory — the same directory this is
3537
+ // running in — so the discovered file has to be validated too, not
3538
+ // just an explicitly named one.
3539
+ let configToCheck;
3540
+ if (selectedConfig) {
3541
+ configToCheck = path.resolve(readWorkingDirectory(env), String(selectedConfig));
3542
+ }
3543
+ else {
3544
+ try {
3545
+ configToCheck = (await resolveConfigLocation({}, env))?.path;
3546
+ }
3547
+ catch (error) {
3548
+ // Discovery throws when a candidate exists but cannot be read.
3549
+ // Treating that as "no config" would install a daemon that hits
3550
+ // the same error on its first dispatch.
3551
+ writeLine(streams.stderr, `Not installing: ${error instanceof Error ? error.message : String(error)}`);
3552
+ writeLine(streams.stderr, 'The daemon would fail the same way on startup. Fix the file, or move it aside.');
3553
+ return 1;
3554
+ }
3555
+ }
3556
+ if (configToCheck) {
3557
+ try {
3558
+ await loadConfigFile(configToCheck);
3559
+ }
3560
+ catch (error) {
3561
+ writeLine(streams.stderr, `Not installing: ${configToCheck} cannot be used (${error instanceof Error ? error.message : String(error)}).`);
3562
+ writeLine(streams.stderr, 'The daemon would exit on startup and be restarted in a loop. Fix the file, or move it aside.');
3563
+ return 1;
3564
+ }
3565
+ }
3566
+ }
3567
+ const action = command.action === 'install'
3568
+ ? installService(serviceEnv, {
3569
+ ...(command.runAtLogin === false ? { runAtLogin: false } : {}),
3570
+ // Absolute: resolved against the directory the install ran in.
3571
+ ...(selectedConfig ? { configPath: path.resolve(readWorkingDirectory(env), String(selectedConfig)) } : {}),
3572
+ })
3573
+ : command.action === 'uninstall'
3574
+ ? uninstallService(serviceEnv)
3575
+ : command.action === 'start'
3576
+ ? startService(serviceEnv)
3577
+ : stopService(serviceEnv);
3578
+ const result = await action;
3579
+ for (const message of result.messages) {
3580
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
3581
+ }
3582
+ return result.ok ? 0 : 1;
3583
+ };
2384
3584
  export const runAgentNew = async (command, streams, env = {}) => {
2385
3585
  // On a real terminal, creating an agent is the same guided experience as
2386
3586
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -2579,12 +3779,35 @@ const loadSlackAdapter = async () => {
2579
3779
  * SIGINT, or the injected shutdown signal.
2580
3780
  */
2581
3781
  export const runServe = async (command, streams, env = {}) => {
3782
+ // First, before anything that can throw. A broken install makes the
3783
+ // gateway import below fail, the manager restarts, and the CLI's error
3784
+ // and help text append to the redirect file again — the crash loop this
3785
+ // bounding exists for. Running it after the import would skip exactly
3786
+ // that case.
3787
+ if (command.logToFile !== false) {
3788
+ await truncateRedirectLogs(logsDirPath(env)).catch(() => undefined);
3789
+ }
2582
3790
  // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
2583
3791
  // command neither needs nor should pay for (Node still prints an
2584
3792
  // experimental warning for it).
2585
3793
  const { createGateway } = await import('@stratusagent/gateway');
2586
- const log = (line) => writeLine(streams.stdout, line);
2587
- const warn = (line) => writeLine(streams.stderr, `Warning: ${line}`);
3794
+ // Under a service manager the daemon's stdout is gone, so everything it
3795
+ // says is also written to ~/.stratus/logs — that file is what `stratus
3796
+ // logs` reads, and the only record of an overnight run.
3797
+ const logWriter = command.logToFile === false
3798
+ ? undefined
3799
+ : createLogWriter({
3800
+ dir: logsDirPath(env),
3801
+ onError: (error) => writeLine(streams.stderr, `Warning: could not write the log file (${error instanceof Error ? error.message : String(error)}); continuing.`),
3802
+ });
3803
+ const log = (line) => {
3804
+ writeLine(streams.stdout, line);
3805
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'info', msg: line });
3806
+ };
3807
+ const warn = (line) => {
3808
+ writeLine(streams.stderr, `Warning: ${line}`);
3809
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'warn', msg: line });
3810
+ };
2588
3811
  // Agents with stored Slack tokens go live in Slack automatically — the
2589
3812
  // tokens are gateway infrastructure secrets in the channels namespace of
2590
3813
  // ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
@@ -2615,8 +3838,61 @@ export const runServe = async (command, streams, env = {}) => {
2615
3838
  + 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
2616
3839
  }
2617
3840
  }
3841
+ // A daemon is the costliest place for a silently demoted subscription:
3842
+ // every dispatch for as long as it runs, with nobody watching. The
3843
+ // gateway resolves each agent's soul independently on dispatch, so an
3844
+ // agent pinning anthropic can pick up an environment key even when the
3845
+ // daemon's own default is openai — checking only the default selection
3846
+ // would miss exactly the agent that is being billed. Each distinct
3847
+ // selection is resolved once here, and duplicate warnings are collapsed
3848
+ // so a ten-agent roster does not print ten identical lines.
3849
+ {
3850
+ const warned = new Set();
3851
+ const collect = (line) => {
3852
+ if (warned.has(line)) {
3853
+ return;
3854
+ }
3855
+ warned.add(line);
3856
+ // Through `warn`, not straight to stderr: under a service manager
3857
+ // stderr is gone, and a cost warning that only exists there is
3858
+ // invisible in exactly the deployment it matters for. `warn` adds
3859
+ // its own prefix, so the one already on the line comes off.
3860
+ warn(line.replace(/^Warning: /, ''));
3861
+ };
3862
+ const captured = {
3863
+ stdout: { write: () => true },
3864
+ stderr: { write: (chunk) => { collect(chunk.replace(/\n$/, '')); return true; } },
3865
+ };
3866
+ // A pinned soul does not merely add a provider — the gateway DEMOTES
3867
+ // the daemon-wide defaults it outranks, including STRATUS_PROVIDER, so
3868
+ // each served runtime is resolved the way a dispatch resolves it.
3869
+ for (const served of await servedRuntimes(env, command.configPath)) {
3870
+ await warnOnCredentialOverride(served.runtime, captured, served.env);
3871
+ }
3872
+ }
3873
+ // The daemon had no policy at all: createGateway falls back to
3874
+ // AllowAllApprovalPolicy, so every tool call from every agent
3875
+ // auto-approved, indefinitely, with nobody watching. That was survivable
3876
+ // while `serve` was something you ran in a terminal you were sitting at;
3877
+ // it is not, now that setup installs it under launchd by default.
3878
+ //
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
+ });
2618
3893
  const gateway = createGateway({
2619
3894
  env,
3895
+ approvals,
2620
3896
  ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
2621
3897
  ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
2622
3898
  ...(channels.length > 0 ? { channels } : {}),
@@ -2631,35 +3907,111 @@ export const runServe = async (command, streams, env = {}) => {
2631
3907
  }
2632
3908
  });
2633
3909
  }
3910
+ if (logWriter) {
3911
+ // Only session.created carries the agent id, so it seeds a map the
3912
+ // later events in that session read from. A session resumed after a
3913
+ // restart never re-creates, so an unmapped id falls back to the
3914
+ // durable store — which is exactly the case where attribution matters.
3915
+ const agentBySession = new Map();
3916
+ // EventBus.emit awaits its subscribers, and a streaming provider awaits
3917
+ // the delta sink — so anything awaited here lands on the critical path
3918
+ // of every streamed token. This handler is deliberately synchronous:
3919
+ // provider.delta is dropped outright (it carries no detail worth
3920
+ // keeping, one record per token), the timestamp is taken now, and the
3921
+ // write is queued without awaiting it.
3922
+ gateway.bus.subscribe((event) => {
3923
+ if (event.type === 'provider.delta') {
3924
+ return;
3925
+ }
3926
+ const ts = new Date().toISOString();
3927
+ if (event.type === 'session.created') {
3928
+ agentBySession.set(event.sessionId, event.agentId);
3929
+ }
3930
+ const detail = eventDetail(event);
3931
+ const base = {
3932
+ ts,
3933
+ level: 'event',
3934
+ event: event.type,
3935
+ sessionId: event.sessionId,
3936
+ ...(detail ? { detail } : {}),
3937
+ };
3938
+ const known = agentBySession.get(event.sessionId);
3939
+ if (known) {
3940
+ void logWriter.write({ ...base, agentId: known });
3941
+ }
3942
+ else {
3943
+ // A session resumed after a restart never re-creates, so its agent
3944
+ // is only in the store. That lookup is deferred off this path; the
3945
+ // timestamp above keeps ordering honest.
3946
+ void gateway.store.get(event.sessionId)
3947
+ .then((session) => {
3948
+ const agentId = session?.agent.id;
3949
+ if (agentId) {
3950
+ agentBySession.set(event.sessionId, agentId);
3951
+ }
3952
+ return logWriter.write({ ...base, ...(agentId ? { agentId } : {}) });
3953
+ })
3954
+ .catch(() => logWriter.write(base));
3955
+ }
3956
+ if (event.type === 'session.completed' || event.type === 'session.failed') {
3957
+ agentBySession.delete(event.sessionId);
3958
+ }
3959
+ });
3960
+ }
2634
3961
  await gateway.start();
2635
3962
  writeLine(streams.stdout, 'Press Ctrl+C to stop.');
2636
- // Hold the event loop open until a shutdown request, then drain: new
2637
- // dispatches are refused while in-flight turns finish.
2638
- await new Promise((resolve) => {
2639
- const keepAlive = setInterval(() => { }, 2_147_000_000);
2640
- let settled = false;
2641
- const shutdown = () => {
2642
- if (settled) {
2643
- return;
3963
+ // And periodically, for a long-running daemon that warns steadily
3964
+ // without ever writing enough records to rotate. Unref'd, so it never
3965
+ // holds the process open. Armed only once the daemon is actually
3966
+ // serving: every step above can throw, and runServe is an exported
3967
+ // function as much as a process entry point — in a host that survives
3968
+ // the failure, a timer armed before the throw would outlive the call
3969
+ // and go on truncating that environment's redirect logs. The finally
3970
+ // below takes it down on every other exit path.
3971
+ let redirectTimer;
3972
+ if (logWriter) {
3973
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
3974
+ redirectTimer.unref?.();
3975
+ }
3976
+ try {
3977
+ // Hold the event loop open until a shutdown request, then drain: new
3978
+ // dispatches are refused while in-flight turns finish.
3979
+ await new Promise((resolve) => {
3980
+ const keepAlive = setInterval(() => { }, 2_147_000_000);
3981
+ let settled = false;
3982
+ const shutdown = () => {
3983
+ if (settled) {
3984
+ return;
3985
+ }
3986
+ settled = true;
3987
+ clearInterval(keepAlive);
3988
+ process.off('SIGTERM', shutdown);
3989
+ process.off('SIGINT', shutdown);
3990
+ resolve();
3991
+ };
3992
+ process.once('SIGTERM', shutdown);
3993
+ process.once('SIGINT', shutdown);
3994
+ if (env.shutdownSignal?.aborted) {
3995
+ shutdown();
2644
3996
  }
2645
- settled = true;
2646
- clearInterval(keepAlive);
2647
- process.off('SIGTERM', shutdown);
2648
- process.off('SIGINT', shutdown);
2649
- resolve();
2650
- };
2651
- process.once('SIGTERM', shutdown);
2652
- process.once('SIGINT', shutdown);
2653
- if (env.shutdownSignal?.aborted) {
2654
- shutdown();
2655
- }
2656
- else {
2657
- env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
2658
- }
2659
- });
2660
- writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
2661
- await gateway.stop();
2662
- return 0;
3997
+ else {
3998
+ env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
3999
+ }
4000
+ });
4001
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
4002
+ await gateway.stop();
4003
+ return 0;
4004
+ }
4005
+ finally {
4006
+ if (redirectTimer) {
4007
+ clearInterval(redirectTimer);
4008
+ }
4009
+ // Writes are queued and dropped on the hot path so logging never sits
4010
+ // on a streamed token. That makes the tail of the log the part most
4011
+ // likely to be lost — and the tail is the shutdown reason, the last
4012
+ // warning, the line explaining a restart.
4013
+ await logWriter?.flush();
4014
+ }
2663
4015
  };
2664
4016
  export const runCli = async ({ argv, streams = process, env = {} }) => {
2665
4017
  try {
@@ -2674,25 +4026,40 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2674
4026
  writeLine(streams.stdout, HELP_TEXT);
2675
4027
  return 0;
2676
4028
  }
4029
+ // Every handler is awaited, never bare-returned: a bare `return
4030
+ // promise` inside try/catch settles the async function before the
4031
+ // catch can see it, so a command that fails at runtime — a gateway
4032
+ // that cannot open its store, a missing credential — would escape as
4033
+ // a raw rejection instead of the error line and exit code below.
2677
4034
  if (command.command === 'agent-new') {
2678
- return runAgentNew(command, streams, resolvedEnv);
4035
+ return await runAgentNew(command, streams, resolvedEnv);
2679
4036
  }
2680
4037
  if (command.command === 'agents') {
2681
- return runAgents(command, streams, resolvedEnv);
4038
+ return await runAgents(command, streams, resolvedEnv);
4039
+ }
4040
+ if (command.command === 'doctor') {
4041
+ return await runDoctor(command, streams, resolvedEnv);
4042
+ }
4043
+ if (command.command === 'logs') {
4044
+ return await runLogs(command, streams, resolvedEnv);
4045
+ }
4046
+ if (command.command === 'service') {
4047
+ return await runService(command, streams, resolvedEnv);
2682
4048
  }
2683
4049
  if (command.command === 'chat') {
2684
- return runChat(command, streams, resolvedEnv);
4050
+ return await runChat(command, streams, resolvedEnv);
2685
4051
  }
2686
4052
  if (command.command === 'setup') {
2687
- return runSetup(command, streams, resolvedEnv);
4053
+ return await runSetup(command, streams, resolvedEnv);
2688
4054
  }
2689
4055
  if (command.command === 'dashboard') {
2690
- return runDashboard(command, streams, resolvedEnv);
4056
+ return await runDashboard(command, streams, resolvedEnv);
2691
4057
  }
2692
4058
  if (command.command === 'serve') {
2693
- return runServe(command, streams, resolvedEnv);
4059
+ return await runServe(command, streams, resolvedEnv);
2694
4060
  }
2695
4061
  const runtime = await resolveRuntimeConfig(command, resolvedEnv);
4062
+ await warnOnCredentialOverride(runtime, streams, resolvedEnv);
2696
4063
  if (command.format === 'text') {
2697
4064
  writeLine(streams.stdout, formatRuntimeBanner(runtime));
2698
4065
  }