@stratusagent/cli 0.2.5 → 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
@@ -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
- 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';
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
+ 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.4.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,44 @@ 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>, --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
104
+ service Keep stratusd running under launchd (macOS) or systemd
105
+ (Linux): install, uninstall, status, start, stop.
106
+ Installing starts it now and at every login
107
+ (--no-login installs without the login trigger,
108
+ --config <path> pins the daemon to one config file)
109
+ logs Read the daemon's log from any terminal: -f to follow,
110
+ -n <count> for backlog, --agent / --session to filter,
111
+ --format json for the raw records
49
112
  agent new Create an agent identity (generates a human-ish name + avatar theme)
50
113
  agents List your agents: who they are, where their souls live, what
51
114
  they run on, what they remember (also: stratus agent list)
115
+ doctor Show what a run would use right now — provider, model, soul —
116
+ and which file or environment variable decided each, then
117
+ flag anything that would surprise you (--format json)
52
118
  dashboard Start the local Stratus Agent dashboard and open it in your browser
53
119
  help Show this help message
54
120
 
@@ -67,7 +133,10 @@ Options:
67
133
  --config Config file path (run: load settings from it, setup: write it)
68
134
  --format Output format: text or json (default: text)
69
135
  --no-events Hide event-by-event progress lines in text mode
70
- --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"
71
140
  --max-turns Maximum provider turns per run (default: 8)
72
141
  --port Dashboard port, defaults to an open local port
73
142
  --host Dashboard host (default: 127.0.0.1)
@@ -172,10 +241,23 @@ export const parseCommand = (argv, env = {}) => {
172
241
  index += 1;
173
242
  continue;
174
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
+ }
175
253
  if (token === '--no-events') {
176
254
  parsed.events = false;
177
255
  continue;
178
256
  }
257
+ if (token === '--no-log-file') {
258
+ parsed.logToFile = false;
259
+ continue;
260
+ }
179
261
  throw new Error(`Unknown option: ${token}`);
180
262
  }
181
263
  return parsed;
@@ -265,6 +347,129 @@ export const parseCommand = (argv, env = {}) => {
265
347
  }
266
348
  return { command: 'agents', format };
267
349
  }
350
+ if (command === 'doctor') {
351
+ let format = 'text';
352
+ let configPath;
353
+ for (let index = 0; index < rest.length; index += 1) {
354
+ const token = rest[index];
355
+ if (!token) {
356
+ continue;
357
+ }
358
+ if (token === '--help' || token === '-h') {
359
+ return { command: 'help' };
360
+ }
361
+ if (token === '--format') {
362
+ const value = readOptionValue(rest, index, '--format');
363
+ if (value !== 'text' && value !== 'json') {
364
+ throw new Error(`Unsupported format: ${value}`);
365
+ }
366
+ format = value;
367
+ index += 1;
368
+ continue;
369
+ }
370
+ if (token === '--config') {
371
+ configPath = readOptionValue(rest, index, '--config');
372
+ index += 1;
373
+ continue;
374
+ }
375
+ throw new Error(`Unknown option: ${token}`);
376
+ }
377
+ return { command: 'doctor', format, ...(configPath ? { configPath } : {}) };
378
+ }
379
+ if (command === 'service') {
380
+ const action = rest[0];
381
+ if (action === '--help' || action === '-h' || action === undefined) {
382
+ return { command: 'help' };
383
+ }
384
+ if (action !== 'install' && action !== 'uninstall' && action !== 'status' && action !== 'start' && action !== 'stop') {
385
+ throw new Error(`Unknown service action: ${action} (expected install, uninstall, status, start, or stop)`);
386
+ }
387
+ const parsed = { command: 'service', action };
388
+ const serviceRest = rest.slice(1);
389
+ for (let index = 0; index < serviceRest.length; index += 1) {
390
+ const token = serviceRest[index];
391
+ if (!token) {
392
+ continue;
393
+ }
394
+ if (token === '--help' || token === '-h') {
395
+ return { command: 'help' };
396
+ }
397
+ if (token === '--no-login') {
398
+ if (action !== 'install') {
399
+ throw new Error('--no-login applies to `stratus service install`.');
400
+ }
401
+ parsed.runAtLogin = false;
402
+ continue;
403
+ }
404
+ if (token === '--config') {
405
+ if (action !== 'install') {
406
+ throw new Error('--config applies to `stratus service install`.');
407
+ }
408
+ parsed.configPath = readOptionValue(serviceRest, index, '--config');
409
+ index += 1;
410
+ continue;
411
+ }
412
+ throw new Error(`Unknown option: ${token}`);
413
+ }
414
+ return parsed;
415
+ }
416
+ if (command === 'logs') {
417
+ let follow = false;
418
+ let limit = 50;
419
+ let agentId;
420
+ let sessionId;
421
+ let format = 'text';
422
+ for (let index = 0; index < rest.length; index += 1) {
423
+ const token = rest[index];
424
+ if (!token) {
425
+ continue;
426
+ }
427
+ if (token === '--help' || token === '-h') {
428
+ return { command: 'help' };
429
+ }
430
+ if (token === '--follow' || token === '-f') {
431
+ follow = true;
432
+ continue;
433
+ }
434
+ if (token === '--lines' || token === '-n') {
435
+ const value = Number(readOptionValue(rest, index, token));
436
+ if (!Number.isFinite(value) || value < 0) {
437
+ throw new Error(`Invalid value for ${token}: ${rest[index + 1] ?? '(missing)'}`);
438
+ }
439
+ limit = Math.floor(value);
440
+ index += 1;
441
+ continue;
442
+ }
443
+ if (token === '--agent') {
444
+ agentId = readOptionValue(rest, index, '--agent');
445
+ index += 1;
446
+ continue;
447
+ }
448
+ if (token === '--session') {
449
+ sessionId = readOptionValue(rest, index, '--session');
450
+ index += 1;
451
+ continue;
452
+ }
453
+ if (token === '--format') {
454
+ const value = readOptionValue(rest, index, '--format');
455
+ if (value !== 'text' && value !== 'json') {
456
+ throw new Error(`Unsupported format: ${value}`);
457
+ }
458
+ format = value;
459
+ index += 1;
460
+ continue;
461
+ }
462
+ throw new Error(`Unknown option: ${token}`);
463
+ }
464
+ return {
465
+ command: 'logs',
466
+ follow,
467
+ limit,
468
+ format,
469
+ ...(agentId ? { agentId } : {}),
470
+ ...(sessionId ? { sessionId } : {}),
471
+ };
472
+ }
268
473
  if (command === 'agent') {
269
474
  const [subcommand, ...agentRest] = rest;
270
475
  if (subcommand === '--help' || subcommand === '-h') {
@@ -464,6 +669,10 @@ export const formatEvent = (event) => {
464
669
  return `• tool.completed ${event.result.toolName} ok=${String(event.result.ok)}`;
465
670
  case 'tool.denied':
466
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}` : ''}`;
467
676
  case 'session.completed':
468
677
  return `• session.completed ${event.sessionId}`;
469
678
  case 'session.failed':
@@ -472,9 +681,92 @@ export const formatEvent = (event) => {
472
681
  return null;
473
682
  }
474
683
  };
475
- // Kept with its historical CLI signature: a parsed run command is a
476
- // RuntimeSelection plus CLI-only fields the resolver ignores.
684
+ /**
685
+ * The fields worth keeping per event type. Tool inputs and message text
686
+ * are deliberately excluded: the log is a trace of what happened, not a
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.
696
+ */
697
+ const eventDetail = (event) => {
698
+ switch (event.type) {
699
+ case 'session.updated':
700
+ return { status: event.status };
701
+ case 'provider.response':
702
+ return { parts: event.parts.length };
703
+ case 'tool.called':
704
+ case 'tool.denied':
705
+ return { tool: event.call.toolName };
706
+ case 'tool.completed':
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
+ };
717
+ case 'session.failed':
718
+ return { error: event.error };
719
+ default:
720
+ return undefined;
721
+ }
722
+ };
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
+ */
477
732
  export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
733
+ /**
734
+ * A saved subscription sign-in silently demoted to per-token billing is the
735
+ * one config surprise that costs money — an API key in the environment
736
+ * outranks the stored credential, and the run otherwise looks identical.
737
+ * Detected from the resolved config: an apiKey where the stored credential
738
+ * is a subscription token means the environment won.
739
+ */
740
+ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
741
+ if (runtime.provider === 'demo') {
742
+ return;
743
+ }
744
+ const stored = (await loadCredentials(env)).anthropic;
745
+ if (stored?.type !== 'oauth_token') {
746
+ return;
747
+ }
748
+ // The resolver records the variable that actually won — guessing it here
749
+ // would name the wrong one whenever a custom apiKeyEnv or the legacy
750
+ // STRATUSCLAW_ prefix supplied the key, sending the reader to unset
751
+ // something that was never the cause.
752
+ const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
753
+ // A fallback demoted the same way costs exactly as much, and only bites
754
+ // once the primary is already failing — the worst moment to discover it.
755
+ // The fallback path consults the provider's own conventional variable.
756
+ const processEnv = readProcessEnv(env);
757
+ const fallbackVar = runtime.fallback?.provider === 'anthropic'
758
+ && runtime.fallback.apiKey !== undefined
759
+ && readNonEmptyString(processEnv.ANTHROPIC_API_KEY) === runtime.fallback.apiKey
760
+ ? 'ANTHROPIC_API_KEY'
761
+ : undefined;
762
+ const culprit = primary ?? fallbackVar;
763
+ if (!culprit) {
764
+ return;
765
+ }
766
+ writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the Claude subscription sign-in saved by \`stratus setup\`, `
767
+ + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
768
+ + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
769
+ };
478
770
  const createApprovalPolicy = (mode, streams, env, ask) => {
479
771
  if (mode === 'always') {
480
772
  return new AllowAllApprovalPolicy();
@@ -636,6 +928,7 @@ export const runChat = async (command, streams, env = {}) => {
636
928
  ...(command.soul ? { soul: command.soul } : {}),
637
929
  ...(command.configPath ? { configPath: command.configPath } : {}),
638
930
  }, env);
931
+ await warnOnCredentialOverride(runtime, streams, env);
639
932
  // Interactive means the real terminal: an injected stdinStream is by
640
933
  // definition not a TTY conversation, so it never gets prompts, ANSI
641
934
  // styling, or a terminal readline wired to a stream that can't take it.
@@ -1357,6 +1650,99 @@ const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
1357
1650
  return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
1358
1651
  }
1359
1652
  };
1653
+ // Slack verification talks to the Web API over plain fetch rather than
1654
+ // through @stratusagent/channel-slack. Setup must stay usable before that
1655
+ // optional package is installed, and the CLI deliberately does not depend
1656
+ // on it (or on the ~9 MB of Slack SDKs underneath).
1657
+ const SLACK_API_ROOT = 'https://slack.com/api';
1658
+ const callSlack = async (method, token, fetchImpl) => {
1659
+ if (typeof fetchImpl !== 'function') {
1660
+ return { ok: false, verdict: { status: 'unreachable', detail: 'fetch is unavailable' } };
1661
+ }
1662
+ try {
1663
+ const response = await fetchImpl(`${SLACK_API_ROOT}/${method}`, {
1664
+ method: 'POST',
1665
+ headers: {
1666
+ authorization: `Bearer ${token}`,
1667
+ 'content-type': 'application/x-www-form-urlencoded',
1668
+ },
1669
+ });
1670
+ if (!response.ok) {
1671
+ return { ok: false, verdict: { status: 'unreachable', detail: `HTTP ${response.status}` } };
1672
+ }
1673
+ const body = await response.json();
1674
+ if (body.ok === true) {
1675
+ return { ok: true, body };
1676
+ }
1677
+ // Slack answers 200 with { ok: false, error } for bad credentials, so
1678
+ // the error string — not the status — is what condemns a token.
1679
+ return { ok: false, verdict: { status: 'rejected', detail: String(body.error ?? 'unknown error') } };
1680
+ }
1681
+ catch (error) {
1682
+ return { ok: false, verdict: { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) } };
1683
+ }
1684
+ };
1685
+ /** Verifies a bot token and reports the identity it belongs to. */
1686
+ const verifySlackBotToken = async (token, fetchImpl) => {
1687
+ const result = await callSlack('auth.test', token, fetchImpl);
1688
+ if (!result.ok) {
1689
+ return result.verdict;
1690
+ }
1691
+ return {
1692
+ status: 'ok',
1693
+ identity: {
1694
+ ...(typeof result.body.user_id === 'string' ? { botUserId: result.body.user_id } : {}),
1695
+ ...(typeof result.body.team === 'string' ? { teamName: result.body.team } : {}),
1696
+ ...(typeof result.body.team_id === 'string' ? { teamId: result.body.team_id } : {}),
1697
+ },
1698
+ };
1699
+ };
1700
+ /**
1701
+ * Verifies an app-level token by opening a Socket Mode URL. The URL is
1702
+ * discarded — this only proves the token carries connections:write, which
1703
+ * is the failure the daemon would otherwise hit at start time.
1704
+ */
1705
+ const verifySlackAppToken = async (token, fetchImpl) => {
1706
+ const result = await callSlack('apps.connections.open', token, fetchImpl);
1707
+ return result.ok ? { status: 'ok', identity: {} } : result.verdict;
1708
+ };
1709
+ // Scopes and events the adapter needs. Kept in step with the manifest
1710
+ // shipped by @stratusagent/channel-slack (a test pins them together) so
1711
+ // setup can hand over a ready-to-paste manifest without depending on that
1712
+ // package being installed.
1713
+ const SLACK_BOT_SCOPES = [
1714
+ 'app_mentions:read',
1715
+ 'chat:write',
1716
+ 'files:write',
1717
+ 'im:history',
1718
+ 'im:read',
1719
+ 'im:write',
1720
+ 'users:read',
1721
+ ];
1722
+ const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
1723
+ export const slackAppManifest = (agentName) => JSON.stringify({
1724
+ display_information: {
1725
+ name: agentName,
1726
+ description: 'A Stratus agent',
1727
+ background_color: '#1a1d21',
1728
+ },
1729
+ features: {
1730
+ bot_user: { display_name: agentName, always_online: true },
1731
+ },
1732
+ oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
1733
+ settings: {
1734
+ event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
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 },
1741
+ org_deploy_enabled: false,
1742
+ socket_mode_enabled: true,
1743
+ token_rotation_enabled: false,
1744
+ },
1745
+ }, null, 2);
1360
1746
  const DEFAULT_SOUL_STARTER = [
1361
1747
  'You are a helpful, warm generalist. Answer first, explain second, and',
1362
1748
  'keep replies short unless the question genuinely needs depth. Use',
@@ -1374,6 +1760,104 @@ const KNOWN_CLAUDE_MODELS = [
1374
1760
  'claude-opus-4-6',
1375
1761
  'claude-sonnet-4-6',
1376
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
+ };
1377
1861
  export const runSetup = async (command, streams, env = {}) => {
1378
1862
  const cwd = readWorkingDirectory(env);
1379
1863
  const processEnv = readProcessEnv(env);
@@ -1394,6 +1878,12 @@ export const runSetup = async (command, streams, env = {}) => {
1394
1878
  // A path passed via --config is not auto-discovered by `stratus run`, so
1395
1879
  // suggested commands must carry it explicitly.
1396
1880
  const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
1881
+ // Set once save() detects a project config shadowing the global one.
1882
+ // Suggested commands must carry it too, or they read a different config
1883
+ // than the one just written — for `serve` that means a different roster,
1884
+ // and Slack apps stored here would be skipped as having no agent.
1885
+ let shadowConfigFlag = '';
1886
+ const serveCommand = () => `stratus serve${runConfigFlag}${shadowConfigFlag}`;
1397
1887
  // Seed from what is already configured, so re-running setup edits instead
1398
1888
  // of clobbering.
1399
1889
  let existing = {};
@@ -1446,6 +1936,24 @@ export const runSetup = async (command, streams, env = {}) => {
1446
1936
  ...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
1447
1937
  credentials: await loadCredentials(env),
1448
1938
  credentialsDirty: false,
1939
+ channels: await loadChannelCredentials(env),
1940
+ channelsDirty: false,
1941
+ // On by default: setup's whole promise is that you finish it and the
1942
+ // agents are working. An always-on runtime you have to remember to
1943
+ // start is not always-on, and every Slack app configured here stays
1944
+ // silent until stratusd is up. An existing install keeps ITS login
1945
+ // setting, though — rerunning setup and pressing save must not quietly
1946
+ // undo a deliberate `service install --no-login`.
1947
+ service: await (async () => {
1948
+ const status = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
1949
+ // An unknown answer (a broken user bus, a timed-out query) must not
1950
+ // read as "they chose --no-login" — that would disable a service on
1951
+ // the next save because of a transient failure.
1952
+ return {
1953
+ install: true,
1954
+ runAtLogin: status?.installed && status.runAtLogin !== undefined ? status.runAtLogin : true,
1955
+ };
1956
+ })(),
1449
1957
  };
1450
1958
  const prompter = createSetupPrompter(baseStreams, env, {
1451
1959
  header: stratusHeaderLines,
@@ -1512,6 +2020,13 @@ export const runSetup = async (command, streams, env = {}) => {
1512
2020
  }
1513
2021
  return state.soulPath;
1514
2022
  };
2023
+ const channelsSummary = () => {
2024
+ const connected = Object.keys(state.channels.slack ?? {}).length;
2025
+ if (connected === 0) {
2026
+ return 'Slack: not connected';
2027
+ }
2028
+ return `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
2029
+ };
1515
2030
  const storeCredential = (provider, credential) => {
1516
2031
  state.credentials[provider] = credential;
1517
2032
  state.credentialsDirty = true;
@@ -1927,17 +2442,13 @@ export const runSetup = async (command, streams, env = {}) => {
1927
2442
  }
1928
2443
  const name = await prompter.ask('Name your agent (Enter to have one generated): ');
1929
2444
  const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
1930
- const agent = defineAgent({
1931
- ...(name ? { name } : {}),
1932
- instructions: instructions || DEFAULT_SOUL_STARTER,
1933
- });
1934
- const soul = formatSoul({
1935
- agent,
1936
- ...(state.provider !== 'demo' ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) } : {}),
1937
- });
1938
- const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
1939
- await mkdir(path.dirname(soulPath), { recursive: true });
1940
- 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;
1941
2452
  state.soulPath = soulPath;
1942
2453
  writeLine(streams.stdout, `Say hello to ${agent.name}.`);
1943
2454
  writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
@@ -2043,6 +2554,279 @@ export const runSetup = async (command, streams, env = {}) => {
2043
2554
  ...(fallback ? { fallback } : {}),
2044
2555
  };
2045
2556
  };
2557
+ /**
2558
+ * Walks an agent through connecting their own Slack app: show the
2559
+ * manifest to paste, take both tokens, verify each against Slack, and
2560
+ * store them under the agent's id. Tokens are keyed by a roster agent
2561
+ * picked from a list rather than typed, so the id can never drift from
2562
+ * the roster — the mismatch that otherwise surfaces only as a startup
2563
+ * warning about an agent the gateway cannot find.
2564
+ */
2565
+ const connectSlackAgent = async (agentId, agentName) => {
2566
+ writeLine(streams.stdout);
2567
+ writeLine(streams.stdout, `Create a Slack app for ${agentName}:`);
2568
+ writeLine(streams.stdout, ' 1. Open https://api.slack.com/apps → Create New App → From a manifest');
2569
+ writeLine(streams.stdout, ' 2. Pick your workspace, then paste this manifest:');
2570
+ writeLine(streams.stdout);
2571
+ for (const line of slackAppManifest(agentName).split('\n')) {
2572
+ writeLine(streams.stdout, ` ${line}`);
2573
+ }
2574
+ writeLine(streams.stdout);
2575
+ writeLine(streams.stdout, ' 3. Create the app, then Basic Information → App-Level Tokens →');
2576
+ writeLine(streams.stdout, ' Generate a token with the connections:write scope (xapp-…)');
2577
+ writeLine(streams.stdout, ' 4. Install App → copy the Bot User OAuth Token (xoxb-…)');
2578
+ writeLine(streams.stdout, ` 5. Basic Information → Display Information → upload ${agentName}'s avatar`);
2579
+ writeLine(streams.stdout);
2580
+ const appToken = await prompter.askSecret('Paste the app-level token (xapp-…, Enter to cancel; input is hidden): ');
2581
+ if (!appToken) {
2582
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
2583
+ return;
2584
+ }
2585
+ if (!appToken.startsWith('xapp-')) {
2586
+ writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-). Nothing was saved.');
2587
+ return;
2588
+ }
2589
+ const botToken = await prompter.askSecret('Paste the bot user OAuth token (xoxb-…, Enter to cancel; input is hidden): ');
2590
+ if (!botToken) {
2591
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
2592
+ return;
2593
+ }
2594
+ if (!botToken.startsWith('xoxb-')) {
2595
+ writeLine(streams.stdout, '✗ That does not look like a bot token (they start with xoxb-). Nothing was saved.');
2596
+ return;
2597
+ }
2598
+ writeLine(streams.stdout, 'Checking the tokens with Slack…');
2599
+ const fetchImpl = env.fetch ?? globalThis.fetch;
2600
+ const bot = await verifySlackBotToken(botToken, fetchImpl);
2601
+ if (bot.status === 'rejected') {
2602
+ writeLine(streams.stdout, `✗ Slack rejected the bot token (${bot.detail}). Nothing was saved — reinstall the app and copy the token again.`);
2603
+ return;
2604
+ }
2605
+ const app = await verifySlackAppToken(appToken, fetchImpl);
2606
+ if (app.status === 'rejected') {
2607
+ writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — check it has the connections:write scope.`);
2608
+ return;
2609
+ }
2610
+ const slack = { ...(state.channels.slack ?? {}) };
2611
+ slack[agentId] = { appToken, botToken };
2612
+ state.channels = { ...state.channels, slack };
2613
+ state.channelsDirty = true;
2614
+ if (bot.status === 'ok' && app.status === 'ok') {
2615
+ const where = bot.identity.teamName ? ` in ${bot.identity.teamName}` : '';
2616
+ const who = bot.identity.botUserId ? ` (bot ${bot.identity.botUserId})` : '';
2617
+ writeLine(streams.stdout, `✓ Verified — ${agentName} is connected to Slack${where}${who}.`);
2618
+ }
2619
+ else {
2620
+ // Unreachable is not a verdict on the tokens: save and let the
2621
+ // daemon report on its first connection attempt.
2622
+ const detail = bot.status === 'unreachable' ? bot.detail : app.detail;
2623
+ writeLine(streams.stdout, `! Could not reach Slack to verify (${detail}). Saved the tokens anyway — \`${serveCommand()}\` will report on startup.`);
2624
+ }
2625
+ };
2626
+ /**
2627
+ * Every agent the gateway would dispatch to: the built-in Stratus
2628
+ * agent, the ~/.stratus/agents roster, and the configured default soul,
2629
+ * which `stratus setup` can point at a file anywhere. The gateway
2630
+ * registers all three, so a Channels list built from the agents
2631
+ * directory alone would hide agents Slack can perfectly well talk to.
2632
+ * Reads state.soulPath rather than the saved config so a soul chosen
2633
+ * earlier in this same setup session is already connectable.
2634
+ */
2635
+ const channelRoster = async () => {
2636
+ const warnOnce = (message) => writeLine(streams.stderr, `Warning: ${message}.`);
2637
+ // Seeded before the roster loads, exactly as loadRoster does it: a
2638
+ // fresh install with no soul files still has an agent to put on
2639
+ // Slack, and a roster file that declares id "stratus" cannot take the
2640
+ // built-in's place — the gateway skips it, so offering it here would
2641
+ // name an app after an agent that never receives the messages.
2642
+ const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
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);
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);
2668
+ // The soul a run resolves to, in resolveSoulPath's own order: an env
2669
+ // override outranks the config value this setup session is editing.
2670
+ // Listing the config soul while `stratus serve` registers the env one
2671
+ // would store tokens against an id the adapter then skips.
2672
+ const processEnv = readProcessEnv(env);
2673
+ const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
2674
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
2675
+ if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
2676
+ warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
2677
+ }
2678
+ const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
2679
+ if (!effectiveSoul) {
2680
+ return { entries, loaded: rosterLoaded };
2681
+ }
2682
+ const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
2683
+ if (entries.some((entry) => entry.path === resolved)) {
2684
+ return { entries, loaded: rosterLoaded };
2685
+ }
2686
+ try {
2687
+ const soul = await loadSoulFile(resolved);
2688
+ // An explicit id can collide with a roster file, or with the
2689
+ // built-in. The gateway's defaultAgentId replaces the registered
2690
+ // source whenever it is pathless (the built-in) or resolves to a
2691
+ // different file, so the configured soul is the one Slack actually
2692
+ // dispatches to — offering the namesake here would connect an app
2693
+ // to a different agent than it names.
2694
+ const collision = entries.findIndex((entry) => entry.soul.agent.id === soul.agent.id);
2695
+ if (collision >= 0) {
2696
+ entries.splice(collision, 1);
2697
+ }
2698
+ entries.unshift({ soul, path: resolved });
2699
+ }
2700
+ catch (error) {
2701
+ warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
2702
+ }
2703
+ return { entries, loaded: rosterLoaded };
2704
+ };
2705
+ const serviceSummary = () => {
2706
+ if (!servicePlatform(serviceEnvFor(env))) {
2707
+ return `not available on ${env.processEnv?.OSTYPE ?? process.platform} — run \`stratus serve\` yourself`;
2708
+ }
2709
+ if (!state.service.install) {
2710
+ return 'off — start stratusd yourself with `stratus serve`';
2711
+ }
2712
+ return state.service.runAtLogin
2713
+ ? 'stratusd runs after setup, and at every login'
2714
+ : 'stratusd runs after setup, but not at login';
2715
+ };
2716
+ /**
2717
+ * Whether the roster keeps answering once this terminal closes. On by
2718
+ * default: an agent you have to remember to start is not always-on, and
2719
+ * every Slack app configured above is silent until stratusd runs.
2720
+ */
2721
+ const chooseService = async () => {
2722
+ if (!servicePlatform(serviceEnvFor(env))) {
2723
+ writeLine(streams.stdout);
2724
+ writeLine(streams.stdout, `Stratus has no service integration for ${process.platform} yet — run \`stratus serve\` yourself, or supervise it however you prefer.`);
2725
+ await prompter.ask('Press Enter to return to the menu… ');
2726
+ return;
2727
+ }
2728
+ const choice = await prompter.select('Always on — keep stratusd running in the background', [
2729
+ 'Run after setup, and start again at every login (recommended)',
2730
+ 'Run after setup, but do not start at login',
2731
+ 'Do not run it for me — I will start `stratus serve` myself',
2732
+ 'Back',
2733
+ ], {
2734
+ footnote: process.platform === 'darwin'
2735
+ // Said here rather than left to be discovered after a reboot.
2736
+ ? 'A LaunchAgent starts at login, not at power-on. For a machine that should recover unattended, turn on automatic login too.'
2737
+ : 'A user service starts at login. `loginctl enable-linger` keeps it up on a machine you do not stay logged in to.',
2738
+ });
2739
+ if (choice.kind !== 'index' || choice.index === 3) {
2740
+ return;
2741
+ }
2742
+ state.service = {
2743
+ install: choice.index !== 2,
2744
+ runAtLogin: choice.index === 0,
2745
+ };
2746
+ };
2747
+ const chooseChannels = async () => {
2748
+ while (true) {
2749
+ const { entries: roster, loaded: rosterLoaded } = await channelRoster();
2750
+ const slack = state.channels.slack ?? {};
2751
+ // The roster always holds at least the built-in agent, so there is
2752
+ // no empty state to short-circuit on — and short-circuiting would
2753
+ // strand orphaned tokens, which are only reachable from this list.
2754
+ const options = roster.map((entry) => {
2755
+ const id = entry.soul.agent.id;
2756
+ const status = slack[id] ? '✓ connected' : '— not connected';
2757
+ return `${entry.soul.agent.name} (${id})`.padEnd(34) + status;
2758
+ });
2759
+ // Orphans: tokens whose agent left the roster would otherwise be
2760
+ // invisible here while still being loaded by `stratus serve`.
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
+ }
2774
+ for (const id of orphans) {
2775
+ options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
2776
+ }
2777
+ options.push('Back');
2778
+ 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.` });
2779
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
2780
+ return;
2781
+ }
2782
+ if (choice.index >= roster.length) {
2783
+ // An orphaned entry — offer to clear it.
2784
+ const orphanId = orphans[choice.index - roster.length];
2785
+ if (!orphanId) {
2786
+ return;
2787
+ }
2788
+ const confirm = await prompter.select(`No agent with id ${orphanId} is in the roster.`, [
2789
+ 'Remove these Slack tokens',
2790
+ 'Keep them',
2791
+ ]);
2792
+ if (confirm.kind === 'index' && confirm.index === 0) {
2793
+ const next = { ...slack };
2794
+ delete next[orphanId];
2795
+ state.channels = { ...state.channels, slack: next };
2796
+ state.channelsDirty = true;
2797
+ writeLine(streams.stdout, `Removed the Slack tokens for ${orphanId}.`);
2798
+ }
2799
+ continue;
2800
+ }
2801
+ const entry = roster[choice.index];
2802
+ if (!entry) {
2803
+ return;
2804
+ }
2805
+ const agentId = entry.soul.agent.id;
2806
+ const agentName = entry.soul.agent.name;
2807
+ if (!slack[agentId]) {
2808
+ await connectSlackAgent(agentId, agentName);
2809
+ continue;
2810
+ }
2811
+ const action = await prompter.select(`${agentName} is connected to Slack.`, [
2812
+ 'Replace the tokens (re-run the app setup)',
2813
+ 'Disconnect from Slack (forget the tokens)',
2814
+ 'Back',
2815
+ ]);
2816
+ if (action.kind !== 'index' || action.index === 2) {
2817
+ continue;
2818
+ }
2819
+ if (action.index === 0) {
2820
+ await connectSlackAgent(agentId, agentName);
2821
+ continue;
2822
+ }
2823
+ const next = { ...slack };
2824
+ delete next[agentId];
2825
+ state.channels = { ...state.channels, slack: next };
2826
+ state.channelsDirty = true;
2827
+ 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.`);
2828
+ }
2829
+ };
2046
2830
  const testRun = async () => {
2047
2831
  const runtime = await buildTestRuntime();
2048
2832
  if (!runtime) {
@@ -2092,7 +2876,6 @@ export const runSetup = async (command, streams, env = {}) => {
2092
2876
  };
2093
2877
  };
2094
2878
  const save = async () => {
2095
- let shadowConfigFlag = '';
2096
2879
  const config = { provider: state.provider };
2097
2880
  if (state.provider !== 'demo') {
2098
2881
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -2149,6 +2932,66 @@ export const runSetup = async (command, streams, env = {}) => {
2149
2932
  await saveCredentials(env, state.credentials);
2150
2933
  writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
2151
2934
  }
2935
+ if (state.channelsDirty) {
2936
+ // Channel tokens live in their own namespace of the same file; the
2937
+ // writers merge, so this never clobbers the provider sign-in above.
2938
+ await saveChannelCredentials(env, state.channels);
2939
+ const connected = Object.keys(state.channels.slack ?? {}).length;
2940
+ writeLine(streams.stdout, connected > 0
2941
+ ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2942
+ : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2943
+ }
2944
+ // Last, so the daemon starts against the config and credentials that
2945
+ // were just written rather than the ones it would have found a moment
2946
+ // ago. A service failure is reported and never fails setup: the
2947
+ // settings are already saved, and `stratus serve` still works by hand.
2948
+ if (!state.service.install && servicePlatform(serviceEnvFor(env))) {
2949
+ // Opting out has to actually take effect. Skipping the install would
2950
+ // leave a unit from an earlier setup running and enabled at login,
2951
+ // while the menu said "off" — still burning provider usage and still
2952
+ // answering in Slack after an explicit opt-out.
2953
+ const existing = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
2954
+ if (existing?.installed) {
2955
+ // Removal deletes the unit file, so it can reject the same way the
2956
+ // install can. Setup's settings are already written by this point;
2957
+ // the optional service must not take the whole command down.
2958
+ const removed = await uninstallService(serviceEnvFor(env)).catch((error) => ({
2959
+ ok: false,
2960
+ messages: [
2961
+ `Could not remove the always-on service: ${error instanceof Error ? error.message : String(error)}`,
2962
+ `${serviceUnitPath(serviceEnvFor(env))} is still in place — remove it by hand, or it will start again at login.`,
2963
+ ],
2964
+ }));
2965
+ for (const message of removed.messages) {
2966
+ writeLine(removed.ok ? streams.stdout : streams.stderr, message);
2967
+ }
2968
+ }
2969
+ }
2970
+ else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
2971
+ // The unit is pinned to the file setup just wrote. Its working
2972
+ // directory is the home directory, so discovery from there would
2973
+ // find a different config whenever setup was run with --config or
2974
+ // STRATUS_CONFIG — the daemon would come up on another roster and
2975
+ // leave the Slack apps configured above offline.
2976
+ // installService writes files, so it can reject outright — an
2977
+ // inaccessible ~/Library/LaunchAgents, a read-only home. Letting
2978
+ // that escape would fail setup itself, after the config and
2979
+ // credentials were already saved, when the always-on service is the
2980
+ // one optional part of it.
2981
+ const result = await installService(serviceEnvFor(env), {
2982
+ runAtLogin: state.service.runAtLogin,
2983
+ configPath,
2984
+ }).catch((error) => ({
2985
+ ok: false,
2986
+ messages: [`Could not install the always-on service: ${error instanceof Error ? error.message : String(error)}`],
2987
+ }));
2988
+ for (const message of result.messages) {
2989
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
2990
+ }
2991
+ if (!result.ok) {
2992
+ writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
2993
+ }
2994
+ }
2152
2995
  writeLine(streams.stdout);
2153
2996
  // Exported STRATUS_* variables outrank the config file, so warn when one
2154
2997
  // would make `stratus run` behave differently from what was just saved.
@@ -2210,11 +3053,13 @@ export const runSetup = async (command, streams, env = {}) => {
2210
3053
  `Providers ${providersSummary()}`,
2211
3054
  `Models ${modelsSummary()}`,
2212
3055
  `Agent ${agentSummary()}`,
3056
+ `Channels ${channelsSummary()}`,
3057
+ `Always on ${serviceSummary()}`,
2213
3058
  'Test run say hello with the current settings',
2214
3059
  'Save & finish',
2215
3060
  ]);
2216
3061
  // Backing out of the top level (Esc, or the input ending) saves.
2217
- if (choice.kind !== 'index' || choice.index === 4) {
3062
+ if (choice.kind !== 'index' || choice.index === 6) {
2218
3063
  break;
2219
3064
  }
2220
3065
  if (choice.index === 0) {
@@ -2227,6 +3072,12 @@ export const runSetup = async (command, streams, env = {}) => {
2227
3072
  await chooseAgent();
2228
3073
  }
2229
3074
  else if (choice.index === 3) {
3075
+ await chooseChannels();
3076
+ }
3077
+ else if (choice.index === 4) {
3078
+ await chooseService();
3079
+ }
3080
+ else if (choice.index === 5) {
2230
3081
  await testRun();
2231
3082
  }
2232
3083
  }
@@ -2381,6 +3232,610 @@ export const runAgents = async (command, streams, env = {}) => {
2381
3232
  : 'Talk to the default with stratus run, or to anyone with stratus run --soul <file>.');
2382
3233
  return 0;
2383
3234
  };
3235
+ /**
3236
+ * `stratus doctor` — what a run would resolve to right now, and which file
3237
+ * or variable decided each part. Every "why is it using X" question needs
3238
+ * the precedence chain, and reading it out of the source is not something
3239
+ * anyone should have to do.
3240
+ */
3241
+ export const collectDoctorReport = async (command, warn, env = {}) => {
3242
+ const processEnv = readProcessEnv(env);
3243
+ const cwd = readWorkingDirectory(env);
3244
+ const problems = [];
3245
+ // Config discovery, spelled out rather than delegated: doctor has to
3246
+ // report the files that LOST as well as the one in use, which is the
3247
+ // whole point when a stray project config is the answer.
3248
+ // Which of the three named it, not just that one did: telling someone to
3249
+ // fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
3250
+ // real override in place — the same mistake as provider and key
3251
+ // attribution, in the one place a typo is most likely.
3252
+ const explicitSource = command.configPath !== undefined
3253
+ ? { name: '--config', value: command.configPath }
3254
+ : readNonEmptyString(processEnv.STRATUS_CONFIG)
3255
+ ? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
3256
+ : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
3257
+ ? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
3258
+ : undefined;
3259
+ const explicit = explicitSource?.value;
3260
+ const candidates = explicitSource
3261
+ ? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
3262
+ : [
3263
+ { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
3264
+ { path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
3265
+ { path: globalConfigPath(env), label: 'global' },
3266
+ ];
3267
+ const present = [];
3268
+ let unreadable;
3269
+ for (const candidate of candidates) {
3270
+ try {
3271
+ await readFile(candidate.path, 'utf8');
3272
+ present.push(candidate);
3273
+ }
3274
+ catch (error) {
3275
+ const code = error.code;
3276
+ if (code !== 'ENOENT') {
3277
+ // resolveConfigLocation throws here rather than trying the next
3278
+ // candidate, so discovery stops: a lower-priority file that a real
3279
+ // run never reaches must not be reported as the config in use.
3280
+ unreadable = candidate;
3281
+ problems.push(`${candidate.path} exists but cannot be read (${error.message}). Every run fails here — discovery never reaches a lower-priority config.`);
3282
+ break;
3283
+ }
3284
+ if (explicit) {
3285
+ // Discovery treats a missing candidate as "try the next one", but
3286
+ // an explicitly named config has no next one: a real run calls
3287
+ // loadConfigFile on it and fails. Reporting built-in defaults here
3288
+ // would describe a run that cannot happen, and hide the typo.
3289
+ problems.push(`${candidate.path} does not exist, but ${candidate.label} names it — every run with this setting fails. `
3290
+ + 'Fix the path, or drop the setting to fall back to config discovery.');
3291
+ }
3292
+ }
3293
+ }
3294
+ const winner = unreadable ?? present[0];
3295
+ const shadowed = unreadable ? [] : present.slice(1);
3296
+ if (winner && shadowed.length > 0 && winner.label.startsWith('project')) {
3297
+ problems.push(`${winner.path} outranks ${shadowed.map((entry) => entry.path).join(' and ')} for runs started in this directory. `
3298
+ + 'Run from elsewhere, or pass --config to pick one explicitly.');
3299
+ }
3300
+ let fileConfig = {};
3301
+ let configFatal = unreadable !== undefined;
3302
+ if (winner && !unreadable) {
3303
+ try {
3304
+ fileConfig = await loadConfigFile(winner.path);
3305
+ }
3306
+ catch (error) {
3307
+ // resolveRuntimeConfig propagates this — no run reaches provider or
3308
+ // credential resolution, so presenting defaults as resolved settings
3309
+ // would describe a run that cannot happen.
3310
+ configFatal = true;
3311
+ problems.push(`${winner.path} could not be parsed (${error.message}). Every run fails until it is fixed or removed.`);
3312
+ warn(`ignoring unreadable config ${winner.path}`);
3313
+ }
3314
+ }
3315
+ // Which variable supplied a value, not just the value: naming the wrong
3316
+ // one sends the reader to unset something that was never the cause,
3317
+ // leaving the real override in place. The legacy STRATUSCLAW_ prefix is
3318
+ // still honored by the resolver, so it has to be reportable too.
3319
+ const envPick = (...names) => {
3320
+ for (const name of names) {
3321
+ const value = readNonEmptyString(processEnv[name]);
3322
+ if (typeof value === 'string') {
3323
+ return { name, value };
3324
+ }
3325
+ }
3326
+ return undefined;
3327
+ };
3328
+ /**
3329
+ * The verdict comes from the resolver, not from a second copy of its
3330
+ * rules. Everything below only *attributes* values to the file or
3331
+ * variable that supplied them — which the resolver does not report —
3332
+ * while whether a run works at all, and what it bills, is whatever
3333
+ * resolveRuntimeConfig actually returns or throws.
3334
+ */
3335
+ const envSoul = envPick('STRATUS_SOUL', 'STRATUSCLAW_SOUL');
3336
+ const soulValue = envSoul?.value ?? fileConfig.soul;
3337
+ const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
3338
+ const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
3339
+ // Loaded here for ATTRIBUTION only — the soul's frontmatter is what
3340
+ // explains a provider nobody wrote in a config file. Whether a run works
3341
+ // is still the resolver's verdict below.
3342
+ const soul = soulPath ? await loadSoulFile(soulPath).catch(() => undefined) : undefined;
3343
+ let resolved;
3344
+ if (!configFatal) {
3345
+ try {
3346
+ resolved = await resolveStateRuntimeConfig(command.configPath ? { configPath: command.configPath } : {}, env);
3347
+ }
3348
+ catch (error) {
3349
+ const message = error instanceof Error ? error.message : String(error);
3350
+ problems.push(`No run can start: ${message}`);
3351
+ if (soulPath && /soul/i.test(message)) {
3352
+ // Worth saying explicitly: a soul that fails to load is fatal, not
3353
+ // a quiet downgrade to the built-in agent.
3354
+ problems.push(`Every run fails until ${soulPath} is fixed or the soul setting is removed — the built-in agent is not substituted.`);
3355
+ }
3356
+ }
3357
+ }
3358
+ const envProviderPick = envPick('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER');
3359
+ const providerSource = envProviderPick
3360
+ ? envProviderPick.name
3361
+ : soul?.provider
3362
+ ? `${soulPath} (soul frontmatter)`
3363
+ : fileConfig.provider
3364
+ ? (winner?.path ?? 'config')
3365
+ : 'built-in default — nothing set a provider';
3366
+ // When resolution fails the intended provider is still worth reporting:
3367
+ // "why is it anthropic" is exactly the question, and the problems below
3368
+ // already say no run can start.
3369
+ const intendedProvider = envProviderPick?.value ?? soul?.provider ?? fileConfig.provider ?? 'demo';
3370
+ const provider = resolved
3371
+ ? { value: resolved.provider, source: providerSource }
3372
+ : { value: `${intendedProvider} (unreachable)`, source: providerSource };
3373
+ if (provider.value === 'demo') {
3374
+ // Setup writes the config file, which is the LOWEST precedence of the
3375
+ // three — so when an env var or a soul chose demo, "run setup" is
3376
+ // advice that changes nothing and leaves every run on demo.
3377
+ const fix = envProviderPick
3378
+ ? `Unset ${envProviderPick.name}; it outranks both your config and any soul.`
3379
+ : soul?.provider === 'demo'
3380
+ ? `Change or remove the provider pin in ${soulPath}; a soul outranks the config file.`
3381
+ : fileConfig.provider === 'demo'
3382
+ ? `Change "provider" in ${winner?.path}, or run \`stratus setup\` → Providers.`
3383
+ : 'Run `stratus setup` → Providers and sign in, then Save & finish.';
3384
+ problems.push(`Provider is the offline demo model, so replies are canned. ${fix}`);
3385
+ }
3386
+ let model;
3387
+ if (resolved && resolved.provider !== 'demo') {
3388
+ const envModel = envPick('STRATUS_MODEL', 'STRATUSCLAW_MODEL');
3389
+ // A soul's model belongs to the soul's provider, and the config's model
3390
+ // to the config's provider — either can be stranded by an override.
3391
+ const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
3392
+ const configModelApplies = (fileConfig.provider ?? 'openai') === resolved.provider;
3393
+ model = {
3394
+ value: resolved.model,
3395
+ source: envModel
3396
+ ? envModel.name
3397
+ : soulModelApplies && soul?.model === resolved.model
3398
+ ? `${soulPath} (soul frontmatter)`
3399
+ : configModelApplies && fileConfig.model === resolved.model
3400
+ ? (winner?.path ?? 'config')
3401
+ : 'built-in default for this provider',
3402
+ };
3403
+ }
3404
+ const credentials = await loadCredentials(env);
3405
+ const signIns = [];
3406
+ const usedKeyEnvVar = resolved && resolved.provider !== 'demo' ? resolved.apiKeyEnvVar : undefined;
3407
+ const usesAuthToken = resolved?.provider === 'anthropic' && resolved.authToken !== undefined;
3408
+ const fallback = resolved && resolved.provider !== 'demo' ? resolved.fallback : undefined;
3409
+ for (const target of ['anthropic', 'openai']) {
3410
+ const stored = credentials[target];
3411
+ const label = stored?.type === 'oauth_token' ? 'Claude subscription (Pro/Max)' : 'API key';
3412
+ const isDefault = resolved?.provider === target;
3413
+ const isFallback = fallback?.provider === target;
3414
+ // A provider no resolved run consults is reported, never diagnosed:
3415
+ // flagging an override on a credential nothing reads is a false alarm.
3416
+ if (!isDefault && !isFallback) {
3417
+ signIns.push({
3418
+ provider: target,
3419
+ status: stored
3420
+ ? `${label} (unused — ${resolved ? resolved.provider : 'nothing'} serves your runs)`
3421
+ : 'not signed in',
3422
+ });
3423
+ continue;
3424
+ }
3425
+ const envVar = isDefault ? usedKeyEnvVar : (target === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY');
3426
+ const viaEnv = isDefault
3427
+ ? usedKeyEnvVar !== undefined
3428
+ : fallback?.apiKey !== undefined && fallback.apiKey === readNonEmptyString(processEnv[String(envVar)]);
3429
+ const where = isDefault ? 'runs are' : 'fallback runs are';
3430
+ if (stored && viaEnv) {
3431
+ signIns.push({ provider: target, status: `${label}, overridden by ${envVar} in your environment` });
3432
+ // The costly case: an env key silently demotes a subscription to
3433
+ // per-token billing, and nothing in a normal run says so.
3434
+ problems.push(stored.type === 'oauth_token'
3435
+ ? `${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.`
3436
+ : `${envVar} in your environment outranks your saved ${target} sign-in. Unset it to use the one \`stratus setup\` stored.`);
3437
+ continue;
3438
+ }
3439
+ if (viaEnv) {
3440
+ signIns.push({ provider: target, status: `using ${envVar} from your environment` });
3441
+ continue;
3442
+ }
3443
+ if (stored) {
3444
+ signIns.push({ provider: target, status: isDefault && usesAuthToken ? `${label} — runs go through the Claude Code runtime` : label });
3445
+ continue;
3446
+ }
3447
+ signIns.push({ provider: target, status: 'not signed in' });
3448
+ }
3449
+ // A fallback the resolver dropped is the quiet failure: the config still
3450
+ // names one, but a failing primary has nothing to retry on.
3451
+ if (fileConfig.fallbackModel !== undefined && resolved && resolved.provider !== 'demo' && !fallback) {
3452
+ 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. `
3453
+ + 'A failing primary model has nothing to retry on.');
3454
+ }
3455
+ const channels = await loadChannelCredentials(env);
3456
+ const slackAgents = Object.keys(channels.slack ?? {}).sort();
3457
+ const slackPackageInstalled = (await loadSlackAdapter()) !== undefined;
3458
+ if (slackAgents.length > 0 && !slackPackageInstalled) {
3459
+ problems.push(`Slack tokens are stored for ${slackAgents.length} agent(s) but @stratusagent/channel-slack is not installed, so \`stratus serve\` skips them. `
3460
+ + 'Install it with: npm install -g @stratusagent/channel-slack');
3461
+ }
3462
+ let rosterCount = 0;
3463
+ try {
3464
+ rosterCount = (await readdir(agentsDirPath(env))).filter((file) => file.endsWith('.md')).length;
3465
+ }
3466
+ catch (error) {
3467
+ if (error.code !== 'ENOENT') {
3468
+ throw error;
3469
+ }
3470
+ }
3471
+ // Tokens keyed to an agent the roster no longer has are loaded and
3472
+ // skipped on every start, silently.
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));
3485
+ rosterIds.add(DEFAULT_STRATUS_AGENT.id);
3486
+ if (soul) {
3487
+ rosterIds.add(soul.agent.id);
3488
+ }
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)) : [];
3495
+ if (orphans.length > 0) {
3496
+ problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
3497
+ + 'Clear them from `stratus setup` → Channels.');
3498
+ }
3499
+ return {
3500
+ ...(winner ? { configInUse: winner.path } : {}),
3501
+ configShadowed: shadowed.map((entry) => entry.path),
3502
+ provider,
3503
+ ...(model ? { model } : {}),
3504
+ ...(soulPath ? { soul: { value: soulPath, source: soulSource } } : {}),
3505
+ ...(soul ? { agent: `${soul.agent.name} (${soul.agent.id})` } : {}),
3506
+ ...(fallback ? { fallback: { provider: fallback.provider, model: fallback.model } } : {}),
3507
+ signIns,
3508
+ slackAgents,
3509
+ slackPackageInstalled,
3510
+ rosterCount,
3511
+ problems,
3512
+ };
3513
+ };
3514
+ export const runDoctor = async (command, streams, env = {}) => {
3515
+ const report = await collectDoctorReport(command, (message) => {
3516
+ writeLine(streams.stderr, `Warning: ${message}.`);
3517
+ }, env);
3518
+ if (command.format === 'json') {
3519
+ writeLine(streams.stdout, JSON.stringify(report, null, 2));
3520
+ return report.problems.length > 0 ? 1 : 0;
3521
+ }
3522
+ const field = (label, setting) => {
3523
+ if (!setting) {
3524
+ return;
3525
+ }
3526
+ writeLine(streams.stdout, ` ${label.padEnd(10)}${setting.value}`);
3527
+ writeLine(streams.stdout, ` ${''.padEnd(10)}from ${setting.source}`);
3528
+ };
3529
+ writeLine(streams.stdout, 'Stratus Agent — what a run would use right now');
3530
+ writeLine(streams.stdout);
3531
+ field('provider', report.provider);
3532
+ field('model', report.model);
3533
+ field('soul', report.soul);
3534
+ if (report.agent) {
3535
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${report.agent}`);
3536
+ }
3537
+ else if (report.soul) {
3538
+ // A soul is configured but did not load — saying "built-in" here would
3539
+ // describe a run that cannot happen.
3540
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}unresolved — the configured soul could not be read`);
3541
+ }
3542
+ else {
3543
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${DEFAULT_STRATUS_AGENT.name} (built-in — no soul configured)`);
3544
+ }
3545
+ writeLine(streams.stdout);
3546
+ writeLine(streams.stdout, 'Files');
3547
+ writeLine(streams.stdout, ` config ${report.configInUse ?? 'none found — built-in defaults apply'}`);
3548
+ for (const shadowedPath of report.configShadowed) {
3549
+ writeLine(streams.stdout, ` (${shadowedPath} exists but is outranked)`);
3550
+ }
3551
+ writeLine(streams.stdout, ` agents ${report.rosterCount} soul file${report.rosterCount === 1 ? '' : 's'}`);
3552
+ if (report.fallback) {
3553
+ writeLine(streams.stdout, ` ${'fallback'.padEnd(10)}${report.fallback.provider} · ${report.fallback.model}`);
3554
+ writeLine(streams.stdout, ` ${''.padEnd(10)}used when the default model errors mid-run`);
3555
+ }
3556
+ writeLine(streams.stdout);
3557
+ writeLine(streams.stdout, 'Sign-ins');
3558
+ for (const entry of report.signIns) {
3559
+ writeLine(streams.stdout, ` ${entry.provider.padEnd(10)}${entry.status}`);
3560
+ }
3561
+ writeLine(streams.stdout);
3562
+ writeLine(streams.stdout, 'Channels');
3563
+ writeLine(streams.stdout, ` slack ${report.slackAgents.length === 0
3564
+ ? 'no agents connected'
3565
+ : `${report.slackAgents.length} connected (${report.slackAgents.join(', ')})`}`);
3566
+ writeLine(streams.stdout, ` @stratusagent/channel-slack ${report.slackPackageInstalled ? 'installed' : 'not installed'}`);
3567
+ writeLine(streams.stdout);
3568
+ if (report.problems.length === 0) {
3569
+ writeLine(streams.stdout, 'No problems found.');
3570
+ return 0;
3571
+ }
3572
+ writeLine(streams.stdout, `${report.problems.length} problem${report.problems.length === 1 ? '' : 's'} found:`);
3573
+ for (const problem of report.problems) {
3574
+ writeLine(streams.stdout, ` ! ${problem}`);
3575
+ }
3576
+ return 1;
3577
+ };
3578
+ /**
3579
+ * `stratus logs` — the daemon's structured log, filtered. `serve` streams
3580
+ * to its own stdout, which is gone the moment it runs under a service
3581
+ * manager; this reads the file it also writes, from any terminal.
3582
+ */
3583
+ export const runLogs = async (command, streams, env = {}) => {
3584
+ const dir = logsDirPath(env);
3585
+ const filter = {
3586
+ ...(command.agentId ? { agentId: command.agentId } : {}),
3587
+ ...(command.sessionId ? { sessionId: command.sessionId } : {}),
3588
+ };
3589
+ const emit = (record) => {
3590
+ writeLine(streams.stdout, command.format === 'json' ? JSON.stringify(record) : formatLogRecord(record));
3591
+ };
3592
+ // Captured BEFORE the backlog is read: the daemon keeps writing while a
3593
+ // large backlog prints, and a follower that takes its offset afterwards
3594
+ // skips everything written in between — permanently.
3595
+ const followFrom = command.follow ? await currentLogPosition(dir) : undefined;
3596
+ // Bounded by the same offset the follower resumes from: without it, a
3597
+ // record written between the two reads is printed by the backlog and
3598
+ // then again by the stream.
3599
+ const recent = await readRecentRecords(dir, command.limit, filter, followFrom);
3600
+ for (const record of recent) {
3601
+ emit(record);
3602
+ }
3603
+ if (!command.follow) {
3604
+ if (recent.length === 0 && command.format === 'text') {
3605
+ writeLine(streams.stdout, `No log records yet in ${dir}. Start the daemon with \`stratus serve\`.`);
3606
+ }
3607
+ return 0;
3608
+ }
3609
+ const controller = new AbortController();
3610
+ const stop = () => controller.abort();
3611
+ process.once('SIGINT', stop);
3612
+ process.once('SIGTERM', stop);
3613
+ env.shutdownSignal?.addEventListener('abort', stop, { once: true });
3614
+ if (env.shutdownSignal?.aborted) {
3615
+ stop();
3616
+ }
3617
+ try {
3618
+ await tailLog({
3619
+ dir,
3620
+ filter,
3621
+ ...(followFrom !== undefined ? { startPosition: followFrom } : {}),
3622
+ signal: controller.signal,
3623
+ onRecord: emit,
3624
+ });
3625
+ }
3626
+ finally {
3627
+ process.off('SIGINT', stop);
3628
+ process.off('SIGTERM', stop);
3629
+ }
3630
+ return 0;
3631
+ };
3632
+ /** Builds the service view of the CLI environment (home, exec paths, runner). */
3633
+ const serviceEnvFor = (env) => ({
3634
+ ...(env.homeDir !== undefined ? { homeDir: env.homeDir } : {}),
3635
+ cwd: readWorkingDirectory(env),
3636
+ ...(env.serviceRunner !== undefined ? { run: env.serviceRunner } : {}),
3637
+ });
3638
+ /**
3639
+ * A credential that exists only in this shell cannot reach the daemon: a
3640
+ * service manager starts with its own environment and never sources a
3641
+ * profile, so the unit would come up unauthenticated while setup had just
3642
+ * reported everything ready. Returns the variable to name, if so.
3643
+ */
3644
+ /**
3645
+ * Every runtime the daemon would resolve: the config-wide default plus one
3646
+ * per roster soul, normalized the way a dispatch normalizes it. A soul that
3647
+ * pins its own provider resolves to different credentials entirely, so any
3648
+ * check that looks only at the default misses exactly the agent that is
3649
+ * misconfigured.
3650
+ */
3651
+ const servedRuntimes = async (env, configPath) => {
3652
+ const { applySoulPins } = await import('@stratusagent/gateway');
3653
+ const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
3654
+ const context = {
3655
+ ...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
3656
+ configPresent: location !== undefined,
3657
+ };
3658
+ const passes = [{ selection: {}, 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) {
3664
+ const normalized = applySoulPins(entry.soul, {}, env, context);
3665
+ passes.push({ selection: normalized.selection, env: normalized.env });
3666
+ }
3667
+ const resolved = [];
3668
+ for (const pass of passes) {
3669
+ // A runtime that cannot resolve is the gateway's to report, per
3670
+ // dispatch and with far better context than a startup pass has.
3671
+ const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
3672
+ if (runtime) {
3673
+ resolved.push({ runtime, env: pass.env });
3674
+ }
3675
+ }
3676
+ return resolved;
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
+ };
3749
+ /**
3750
+ * `stratus service` — run the daemon under launchd or systemd, so it
3751
+ * survives logout, crashes, and reboots. `serve` itself stays a plain
3752
+ * foreground process; this only tells the platform how to keep it up.
3753
+ */
3754
+ export const runService = async (command, streams, env = {}) => {
3755
+ const serviceEnv = serviceEnvFor(env);
3756
+ if (command.action === 'status') {
3757
+ const status = await readServiceStatus(serviceEnv);
3758
+ if (!status) {
3759
+ writeLine(streams.stdout, `No service manager for ${process.platform}. Run \`stratus serve\` yourself.`);
3760
+ return 1;
3761
+ }
3762
+ writeLine(streams.stdout, `stratusd ${status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3763
+ writeLine(streams.stdout, ` manager ${status.platform}`);
3764
+ writeLine(streams.stdout, ` unit ${status.unitPath}`);
3765
+ if (status.detail) {
3766
+ writeLine(streams.stdout, ` note ${status.detail}`);
3767
+ }
3768
+ if (status.installed) {
3769
+ writeLine(streams.stdout, ` at login ${status.runAtLogin === undefined
3770
+ ? 'unknown — the service manager did not answer'
3771
+ : status.runAtLogin ? 'yes' : 'no'}`);
3772
+ }
3773
+ writeLine(streams.stdout, status.installed
3774
+ ? ' logs stratus logs -f'
3775
+ : ' install stratus service install');
3776
+ return status.running ? 0 : 1;
3777
+ }
3778
+ // A service manager passes none of this shell's environment on, so a
3779
+ // config selected by STRATUS_CONFIG has to be baked into the unit
3780
+ // exactly as --config is. Without it the daemon rediscovers from the
3781
+ // install directory and can come up on a different roster entirely.
3782
+ const processEnv = readProcessEnv(env);
3783
+ const selectedConfig = command.configPath
3784
+ ?? readNonEmptyString(processEnv.STRATUS_CONFIG)
3785
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
3786
+ if (command.action === 'install') {
3787
+ // A config the daemon cannot parse kills it during gateway.start(),
3788
+ // and the manager — having accepted the start — restarts it on a
3789
+ // loop. Better to refuse now, while there is someone reading stderr.
3790
+ // Without an explicit selection the unit carries no --config flag and
3791
+ // discovers from its working directory — the same directory this is
3792
+ // running in — so the discovered file has to be validated too, not
3793
+ // just an explicitly named one.
3794
+ let configToCheck;
3795
+ if (selectedConfig) {
3796
+ configToCheck = path.resolve(readWorkingDirectory(env), String(selectedConfig));
3797
+ }
3798
+ else {
3799
+ try {
3800
+ configToCheck = (await resolveConfigLocation({}, env))?.path;
3801
+ }
3802
+ catch (error) {
3803
+ // Discovery throws when a candidate exists but cannot be read.
3804
+ // Treating that as "no config" would install a daemon that hits
3805
+ // the same error on its first dispatch.
3806
+ writeLine(streams.stderr, `Not installing: ${error instanceof Error ? error.message : String(error)}`);
3807
+ writeLine(streams.stderr, 'The daemon would fail the same way on startup. Fix the file, or move it aside.');
3808
+ return 1;
3809
+ }
3810
+ }
3811
+ if (configToCheck) {
3812
+ try {
3813
+ await loadConfigFile(configToCheck);
3814
+ }
3815
+ catch (error) {
3816
+ writeLine(streams.stderr, `Not installing: ${configToCheck} cannot be used (${error instanceof Error ? error.message : String(error)}).`);
3817
+ writeLine(streams.stderr, 'The daemon would exit on startup and be restarted in a loop. Fix the file, or move it aside.');
3818
+ return 1;
3819
+ }
3820
+ }
3821
+ }
3822
+ const action = command.action === 'install'
3823
+ ? installService(serviceEnv, {
3824
+ ...(command.runAtLogin === false ? { runAtLogin: false } : {}),
3825
+ // Absolute: resolved against the directory the install ran in.
3826
+ ...(selectedConfig ? { configPath: path.resolve(readWorkingDirectory(env), String(selectedConfig)) } : {}),
3827
+ })
3828
+ : command.action === 'uninstall'
3829
+ ? uninstallService(serviceEnv)
3830
+ : command.action === 'start'
3831
+ ? startService(serviceEnv)
3832
+ : stopService(serviceEnv);
3833
+ const result = await action;
3834
+ for (const message of result.messages) {
3835
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
3836
+ }
3837
+ return result.ok ? 0 : 1;
3838
+ };
2384
3839
  export const runAgentNew = async (command, streams, env = {}) => {
2385
3840
  // On a real terminal, creating an agent is the same guided experience as
2386
3841
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -2404,7 +3859,6 @@ export const runAgentNew = async (command, streams, env = {}) => {
2404
3859
  const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
2405
3860
  const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
2406
3861
  const persona = instructions || DEFAULT_SOUL_STARTER;
2407
- let agent = defineAgent({ name, instructions: persona });
2408
3862
  // Frontmatter pins what a run from this directory would actually use:
2409
3863
  // env vars outrank the active config file (project-local or explicit
2410
3864
  // first, global otherwise), and no provider anywhere falls back to
@@ -2429,27 +3883,9 @@ export const runAgentNew = async (command, streams, env = {}) => {
2429
3883
  ?? (configModelApplies ? activeConfig.model : undefined)
2430
3884
  ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
2431
3885
  const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
2432
- // The name stays theirs, but the id — the soul filename and the
2433
- // memory key — must be unique: the suggestion pool is small, so a
2434
- // repeat name would otherwise silently overwrite an earlier agent's
2435
- // soul and share their memory. 'wx' makes each claim atomic; on a
2436
- // collision the id gets a fresh suffix and we try again.
2437
3886
  await mkdir(agentsDirPath(env), { recursive: true });
2438
- const baseId = agent.id;
2439
- let soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2440
- for (;;) {
2441
- try {
2442
- await writeFile(soulPath, formatSoul({ agent, ...soulPin }), { flag: 'wx' });
2443
- break;
2444
- }
2445
- catch (error) {
2446
- if (error.code !== 'EEXIST') {
2447
- throw error;
2448
- }
2449
- agent = defineAgent({ id: `${baseId}-${randomUUID().slice(0, 4)}`, name, instructions: persona });
2450
- soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2451
- }
2452
- }
3887
+ const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
3888
+ const { agent, soulPath } = claimed;
2453
3889
  const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
2454
3890
  'Yes — every stratus run talks to them',
2455
3891
  'Not now',
@@ -2579,16 +4015,44 @@ const loadSlackAdapter = async () => {
2579
4015
  * SIGINT, or the injected shutdown signal.
2580
4016
  */
2581
4017
  export const runServe = async (command, streams, env = {}) => {
4018
+ // First, before anything that can throw. A broken install makes the
4019
+ // gateway import below fail, the manager restarts, and the CLI's error
4020
+ // and help text append to the redirect file again — the crash loop this
4021
+ // bounding exists for. Running it after the import would skip exactly
4022
+ // that case.
4023
+ if (command.logToFile !== false) {
4024
+ await truncateRedirectLogs(logsDirPath(env)).catch(() => undefined);
4025
+ }
2582
4026
  // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
2583
4027
  // command neither needs nor should pay for (Node still prints an
2584
4028
  // experimental warning for it).
2585
4029
  const { createGateway } = await import('@stratusagent/gateway');
2586
- const log = (line) => writeLine(streams.stdout, line);
2587
- const warn = (line) => writeLine(streams.stderr, `Warning: ${line}`);
4030
+ // Under a service manager the daemon's stdout is gone, so everything it
4031
+ // says is also written to ~/.stratus/logs — that file is what `stratus
4032
+ // logs` reads, and the only record of an overnight run.
4033
+ const logWriter = command.logToFile === false
4034
+ ? undefined
4035
+ : createLogWriter({
4036
+ dir: logsDirPath(env),
4037
+ onError: (error) => writeLine(streams.stderr, `Warning: could not write the log file (${error instanceof Error ? error.message : String(error)}); continuing.`),
4038
+ });
4039
+ const log = (line) => {
4040
+ writeLine(streams.stdout, line);
4041
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'info', msg: line });
4042
+ };
4043
+ const warn = (line) => {
4044
+ writeLine(streams.stderr, `Warning: ${line}`);
4045
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'warn', msg: line });
4046
+ };
2588
4047
  // Agents with stored Slack tokens go live in Slack automatically — the
2589
4048
  // tokens are gateway infrastructure secrets in the channels namespace of
2590
4049
  // ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
2591
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';
2592
4056
  const channelCredentials = await loadChannelCredentials(env);
2593
4057
  const slackAgents = Object.entries(channelCredentials.slack ?? {});
2594
4058
  const channels = [];
@@ -2601,11 +4065,16 @@ export const runServe = async (command, streams, env = {}) => {
2601
4065
  const adapter = await loadSlackAdapter();
2602
4066
  if (adapter) {
2603
4067
  channels.push(adapter({
2604
- agents: slackAgents.map(([agentId, tokens]) => ({
2605
- agentId,
2606
- appToken: tokens.appToken,
2607
- botToken: tokens.botToken,
2608
- })),
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
+ }),
2609
4078
  log,
2610
4079
  warn,
2611
4080
  }));
@@ -2615,8 +4084,74 @@ export const runServe = async (command, streams, env = {}) => {
2615
4084
  + 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
2616
4085
  }
2617
4086
  }
4087
+ // A daemon is the costliest place for a silently demoted subscription:
4088
+ // every dispatch for as long as it runs, with nobody watching. The
4089
+ // gateway resolves each agent's soul independently on dispatch, so an
4090
+ // agent pinning anthropic can pick up an environment key even when the
4091
+ // daemon's own default is openai — checking only the default selection
4092
+ // would miss exactly the agent that is being billed. Each distinct
4093
+ // selection is resolved once here, and duplicate warnings are collapsed
4094
+ // so a ten-agent roster does not print ten identical lines.
4095
+ {
4096
+ const warned = new Set();
4097
+ const collect = (line) => {
4098
+ if (warned.has(line)) {
4099
+ return;
4100
+ }
4101
+ warned.add(line);
4102
+ // Through `warn`, not straight to stderr: under a service manager
4103
+ // stderr is gone, and a cost warning that only exists there is
4104
+ // invisible in exactly the deployment it matters for. `warn` adds
4105
+ // its own prefix, so the one already on the line comes off.
4106
+ warn(line.replace(/^Warning: /, ''));
4107
+ };
4108
+ const captured = {
4109
+ stdout: { write: () => true },
4110
+ stderr: { write: (chunk) => { collect(chunk.replace(/\n$/, '')); return true; } },
4111
+ };
4112
+ // A pinned soul does not merely add a provider — the gateway DEMOTES
4113
+ // the daemon-wide defaults it outranks, including STRATUS_PROVIDER, so
4114
+ // each served runtime is resolved the way a dispatch resolves it.
4115
+ for (const served of await servedRuntimes(env, command.configPath)) {
4116
+ await warnOnCredentialOverride(served.runtime, captured, served.env);
4117
+ }
4118
+ }
4119
+ // The daemon had no policy at all: createGateway falls back to
4120
+ // AllowAllApprovalPolicy, so every tool call from every agent
4121
+ // auto-approved, indefinitely, with nobody watching. That was survivable
4122
+ // while `serve` was something you ran in a terminal you were sitting at;
4123
+ // it is not, now that setup installs it under launchd by default.
4124
+ //
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
+ }
2618
4151
  const gateway = createGateway({
2619
4152
  env,
4153
+ approvals,
4154
+ ...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
2620
4155
  ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
2621
4156
  ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
2622
4157
  ...(channels.length > 0 ? { channels } : {}),
@@ -2631,35 +4166,126 @@ export const runServe = async (command, streams, env = {}) => {
2631
4166
  }
2632
4167
  });
2633
4168
  }
2634
- await gateway.start();
2635
- 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) {
4169
+ if (logWriter) {
4170
+ // Only session.created carries the agent id, so it seeds a map the
4171
+ // later events in that session read from. A session resumed after a
4172
+ // restart never re-creates, so an unmapped id falls back to the
4173
+ // durable store — which is exactly the case where attribution matters.
4174
+ const agentBySession = new Map();
4175
+ // EventBus.emit awaits its subscribers, and a streaming provider awaits
4176
+ // the delta sink — so anything awaited here lands on the critical path
4177
+ // of every streamed token. This handler is deliberately synchronous:
4178
+ // provider.delta is dropped outright (it carries no detail worth
4179
+ // keeping, one record per token), the timestamp is taken now, and the
4180
+ // write is queued without awaiting it.
4181
+ gateway.bus.subscribe((event) => {
4182
+ if (event.type === 'provider.delta') {
2643
4183
  return;
2644
4184
  }
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 });
4185
+ const ts = new Date().toISOString();
4186
+ if (event.type === 'session.created') {
4187
+ agentBySession.set(event.sessionId, event.agentId);
4188
+ }
4189
+ const detail = eventDetail(event);
4190
+ const base = {
4191
+ ts,
4192
+ level: 'event',
4193
+ event: event.type,
4194
+ sessionId: event.sessionId,
4195
+ ...(detail ? { detail } : {}),
4196
+ };
4197
+ const known = agentBySession.get(event.sessionId);
4198
+ if (known) {
4199
+ void logWriter.write({ ...base, agentId: known });
4200
+ }
4201
+ else {
4202
+ // A session resumed after a restart never re-creates, so its agent
4203
+ // is only in the store. That lookup is deferred off this path; the
4204
+ // timestamp above keeps ordering honest.
4205
+ void gateway.store.get(event.sessionId)
4206
+ .then((session) => {
4207
+ const agentId = session?.agent.id;
4208
+ if (agentId) {
4209
+ agentBySession.set(event.sessionId, agentId);
4210
+ }
4211
+ return logWriter.write({ ...base, ...(agentId ? { agentId } : {}) });
4212
+ })
4213
+ .catch(() => logWriter.write(base));
4214
+ }
4215
+ if (event.type === 'session.completed' || event.type === 'session.failed') {
4216
+ agentBySession.delete(event.sessionId);
4217
+ }
4218
+ });
4219
+ }
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.');
2658
4234
  }
2659
- });
2660
- writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
2661
- await gateway.stop();
2662
- return 0;
4235
+ }
4236
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
4237
+ // And periodically, for a long-running daemon that warns steadily
4238
+ // without ever writing enough records to rotate. Unref'd, so it never
4239
+ // holds the process open. Armed only once the daemon is actually
4240
+ // serving: every step above can throw, and runServe is an exported
4241
+ // function as much as a process entry point — in a host that survives
4242
+ // the failure, a timer armed before the throw would outlive the call
4243
+ // and go on truncating that environment's redirect logs. The finally
4244
+ // below takes it down on every other exit path.
4245
+ let redirectTimer;
4246
+ if (logWriter) {
4247
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
4248
+ redirectTimer.unref?.();
4249
+ }
4250
+ try {
4251
+ // Hold the event loop open until a shutdown request, then drain: new
4252
+ // dispatches are refused while in-flight turns finish.
4253
+ await new Promise((resolve) => {
4254
+ const keepAlive = setInterval(() => { }, 2_147_000_000);
4255
+ let settled = false;
4256
+ const shutdown = () => {
4257
+ if (settled) {
4258
+ return;
4259
+ }
4260
+ settled = true;
4261
+ clearInterval(keepAlive);
4262
+ process.off('SIGTERM', shutdown);
4263
+ process.off('SIGINT', shutdown);
4264
+ resolve();
4265
+ };
4266
+ process.once('SIGTERM', shutdown);
4267
+ process.once('SIGINT', shutdown);
4268
+ if (env.shutdownSignal?.aborted) {
4269
+ shutdown();
4270
+ }
4271
+ else {
4272
+ env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
4273
+ }
4274
+ });
4275
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
4276
+ await gateway.stop();
4277
+ return 0;
4278
+ }
4279
+ finally {
4280
+ if (redirectTimer) {
4281
+ clearInterval(redirectTimer);
4282
+ }
4283
+ // Writes are queued and dropped on the hot path so logging never sits
4284
+ // on a streamed token. That makes the tail of the log the part most
4285
+ // likely to be lost — and the tail is the shutdown reason, the last
4286
+ // warning, the line explaining a restart.
4287
+ await logWriter?.flush();
4288
+ }
2663
4289
  };
2664
4290
  export const runCli = async ({ argv, streams = process, env = {} }) => {
2665
4291
  try {
@@ -2674,25 +4300,40 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2674
4300
  writeLine(streams.stdout, HELP_TEXT);
2675
4301
  return 0;
2676
4302
  }
4303
+ // Every handler is awaited, never bare-returned: a bare `return
4304
+ // promise` inside try/catch settles the async function before the
4305
+ // catch can see it, so a command that fails at runtime — a gateway
4306
+ // that cannot open its store, a missing credential — would escape as
4307
+ // a raw rejection instead of the error line and exit code below.
2677
4308
  if (command.command === 'agent-new') {
2678
- return runAgentNew(command, streams, resolvedEnv);
4309
+ return await runAgentNew(command, streams, resolvedEnv);
2679
4310
  }
2680
4311
  if (command.command === 'agents') {
2681
- return runAgents(command, streams, resolvedEnv);
4312
+ return await runAgents(command, streams, resolvedEnv);
4313
+ }
4314
+ if (command.command === 'doctor') {
4315
+ return await runDoctor(command, streams, resolvedEnv);
4316
+ }
4317
+ if (command.command === 'logs') {
4318
+ return await runLogs(command, streams, resolvedEnv);
4319
+ }
4320
+ if (command.command === 'service') {
4321
+ return await runService(command, streams, resolvedEnv);
2682
4322
  }
2683
4323
  if (command.command === 'chat') {
2684
- return runChat(command, streams, resolvedEnv);
4324
+ return await runChat(command, streams, resolvedEnv);
2685
4325
  }
2686
4326
  if (command.command === 'setup') {
2687
- return runSetup(command, streams, resolvedEnv);
4327
+ return await runSetup(command, streams, resolvedEnv);
2688
4328
  }
2689
4329
  if (command.command === 'dashboard') {
2690
- return runDashboard(command, streams, resolvedEnv);
4330
+ return await runDashboard(command, streams, resolvedEnv);
2691
4331
  }
2692
4332
  if (command.command === 'serve') {
2693
- return runServe(command, streams, resolvedEnv);
4333
+ return await runServe(command, streams, resolvedEnv);
2694
4334
  }
2695
4335
  const runtime = await resolveRuntimeConfig(command, resolvedEnv);
4336
+ await warnOnCredentialOverride(runtime, streams, resolvedEnv);
2696
4337
  if (command.format === 'text') {
2697
4338
  writeLine(streams.stdout, formatRuntimeBanner(runtime));
2698
4339
  }