@stratusagent/cli 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,54 +1,74 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { appendFile, chmod, mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
5
  import { once } from 'node:events';
6
- import os from 'node:os';
7
6
  import path from 'node:path';
8
7
  import { createInterface } from 'node:readline';
9
8
  import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
10
- import { createLocalCommandExecutor, defineLocalCommandTool, } from '@stratusagent/executor-local';
11
- import { createOpenAICompatibleProvider, createProviderResponseBuilder, defineProvider, } from '@stratusagent/providers';
12
- import { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
13
- import { createClaudeCodeProvider } from '@stratusagent/provider-claude-code';
9
+ import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
10
+ import { createPermissionPolicy } from '@stratusagent/permissions';
11
+ import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
14
12
  import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
15
- export const CLI_VERSION = '0.2.2';
16
- // The agent every run uses when no soul is configured. A Stratus agent is
17
- // a Stratus agent — never "the model" — whichever provider serves it.
18
- const DEFAULT_STRATUS_AGENT = {
19
- id: 'stratus',
20
- name: 'Stratus',
21
- instructions: 'You are Stratus, a personal agent on the Stratus Agent platform. Be warm, direct, and concise. When asked who or what you are, you are Stratus — a Stratus agent — regardless of which model is serving the conversation.',
22
- };
23
- // Unsouled runs used to remember facts under a per-provider default agent.
24
- // Stratus inherits all of them: reads for the built-in agent also return
25
- // entries stored under the legacy ids, while new facts land under 'stratus'.
26
- const LEGACY_DEFAULT_AGENT_IDS = ['demo-agent', 'anthropic-agent', 'openai-agent'];
27
- const withLegacyDefaultMemories = (store) => ({
28
- append: (agentId, content, metadata) => store.append(agentId, content, metadata),
29
- async list(agentId) {
30
- if (agentId !== DEFAULT_STRATUS_AGENT.id) {
31
- return store.list(agentId);
32
- }
33
- const batches = await Promise.all([DEFAULT_STRATUS_AGENT.id, ...LEGACY_DEFAULT_AGENT_IDS].map((id) => store.list(id)));
34
- return batches.flat().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
35
- },
36
- });
37
- const DEFAULT_CONFIG_FILENAME = 'stratus.config.json';
38
- const LEGACY_CONFIG_FILENAME = 'stratusclaw.config.json';
39
- const STRATUS_HOME_DIRNAME = '.stratus';
40
- const GLOBAL_CONFIG_FILENAME = 'config.json';
41
- const CREDENTIALS_FILENAME = 'credentials.json';
42
- const AGENTS_DIRNAME = 'agents';
43
- const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
44
- const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
45
- const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini';
13
+ import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
14
+ import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
15
+ export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
16
+ import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
17
+ export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, parseLogLines, readRecentRecords, tailLog, } from "./logs.js";
18
+ // The shared state package owns config resolution, credentials, memory, and
19
+ // provider wiring now (the gateway uses the same code); the CLI re-exports
20
+ // its historical surface so existing importers keep working.
21
+ export { createFileMemoryStore } from '@stratusagent/state';
22
+ // Kept in step with this package's own version by a test, because it is a
23
+ // second copy of a number nobody re-reads: it is drawn in the setup header
24
+ // and served from the dashboard's /api/status, so drift here misreports the
25
+ // running build rather than failing anything.
26
+ export const CLI_VERSION = '0.3.0';
46
27
  const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
47
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
+ };
48
66
  const HELP_TEXT = `Stratus Agent CLI
49
67
 
50
68
  Usage:
51
69
  stratus setup
70
+ stratus chat
71
+ stratus chat --soul ./examples/souls/ava.md
52
72
  stratus run --prompt "Use the demo tool"
53
73
  stratus run "Say hello"
54
74
  ANTHROPIC_API_KEY=... stratus run --provider anthropic "Say hello"
@@ -56,16 +76,44 @@ Usage:
56
76
  echo "Use the echo tool" | stratus run --stdin
57
77
  STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
58
78
  stratus run --config ./stratus.config.json --provider openai "Say hello"
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
59
85
  stratus dashboard
60
86
  stratus dashboard --port 4123 --host 0.0.0.0 --no-open
61
87
 
62
88
  Commands:
63
89
  setup Menu-driven onboarding: pick a provider, sign in (Claude
64
- subscription or API key), create your agent, and test it —
65
- settings go to ~/.stratus/config.json, sign-ins to
66
- ~/.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)
94
+ chat Talk with your agent — the conversation persists across turns
95
+ and remembered facts accumulate; /exit or Ctrl+C to leave
67
96
  run Execute one local Stratus Agent session
97
+ serve Run stratusd, the always-on gateway: durable sessions, the
98
+ whole roster live at once (each agent on its own provider),
99
+ delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
100
+ (--idle-timeout <seconds>, --no-events, --no-log-file,
101
+ --config <path>); everything it says is also written to
102
+ ~/.stratus/logs, which "stratus logs" reads
103
+ service Keep stratusd running under launchd (macOS) or systemd
104
+ (Linux): install, uninstall, status, start, stop.
105
+ Installing starts it now and at every login
106
+ (--no-login installs without the login trigger,
107
+ --config <path> pins the daemon to one config file)
108
+ logs Read the daemon's log from any terminal: -f to follow,
109
+ -n <count> for backlog, --agent / --session to filter,
110
+ --format json for the raw records
68
111
  agent new Create an agent identity (generates a human-ish name + avatar theme)
112
+ agents List your agents: who they are, where their souls live, what
113
+ they run on, what they remember (also: stratus agent list)
114
+ doctor Show what a run would use right now — provider, model, soul —
115
+ and which file or environment variable decided each, then
116
+ flag anything that would surprise you (--format json)
69
117
  dashboard Start the local Stratus Agent dashboard and open it in your browser
70
118
  help Show this help message
71
119
 
@@ -110,207 +158,6 @@ const stringifyValue = (value) => {
110
158
  }
111
159
  return JSON.stringify(value, null, 2);
112
160
  };
113
- const createDemoTool = () => defineLocalCommandTool({
114
- name: 'demo.echo',
115
- description: 'Return a tiny transformed summary for CLI demos through a real local process.',
116
- parameters: {
117
- type: 'object',
118
- properties: {
119
- text: { type: 'string', description: 'Text to echo back through the local process.' },
120
- },
121
- required: ['text'],
122
- },
123
- async createCommand(input) {
124
- const text = typeof input.text === 'string' ? input.text : '';
125
- const normalized = text.trim() || 'empty input';
126
- const script = `const text = ${JSON.stringify(normalized)}; console.log(JSON.stringify({ received: text, uppercase: text.toUpperCase(), length: text.length }));`;
127
- return {
128
- command: process.execPath,
129
- args: ['-e', script],
130
- };
131
- },
132
- parseResult(result) {
133
- return JSON.parse(result.stdout);
134
- },
135
- });
136
- const createDemoProvider = () => defineProvider({
137
- name: 'demo',
138
- async generate({ session }) {
139
- const builder = createProviderResponseBuilder();
140
- const lastMessage = session.messages.at(-1);
141
- if (lastMessage?.role === 'tool') {
142
- const result = lastMessage.toolResult;
143
- if (result?.ok) {
144
- builder.addText(`The demo.echo tool finished with: ${JSON.stringify(result.output)}`);
145
- }
146
- else {
147
- builder.addText(`The demo.echo tool did not run (${result?.error ?? 'unknown error'}), so this run ends here.`);
148
- }
149
- return builder.done();
150
- }
151
- const prompt = [...session.messages].reverse().find((message) => message.role === 'user')?.content?.trim() ?? '';
152
- const wantsTool = /\b(tool|echo|uppercase|inspect)\b/i.test(prompt);
153
- builder.addText(`Demo provider ready. Prompt received: ${prompt || '(empty)'}`);
154
- if (wantsTool) {
155
- builder.addToolCall({
156
- id: `${session.id}:call:demo-echo`,
157
- toolName: 'demo.echo',
158
- input: { text: prompt },
159
- });
160
- }
161
- else {
162
- builder.addText('No tool call was needed, so this run stays text-only. Mention “tool” or “echo” to trigger the demo tool.');
163
- }
164
- return builder.done();
165
- },
166
- });
167
- const MEMORY_FILENAME = 'memory.jsonl';
168
- const memoryFilePath = (env) => path.join(stratusHomePath(env), MEMORY_FILENAME);
169
- // Memory used to live under the working directory. Fold any such file into
170
- // the global store the first time a run happens from that directory, then
171
- // archive it — an upgrade must never look like the agent forgot.
172
- //
173
- // Every import first takes exclusive ownership by atomically renaming its
174
- // source to a unique claim file: of any competing processes, exactly one
175
- // wins the rename and the rest see ENOENT. A crash mid-import leaves the
176
- // claim file behind; later runs re-claim it the same way and finish the
177
- // job, with entries deduped against the global store by id. Only records
178
- // that parse as real memory entries are imported — malformed lines stay in
179
- // the archive instead of poisoning the global store for every agent.
180
- const isMemoryEntryLine = (line) => {
181
- try {
182
- const parsed = JSON.parse(line);
183
- return typeof parsed === 'object' && parsed !== null
184
- && typeof parsed.id === 'string'
185
- && typeof parsed.agentId === 'string'
186
- && typeof parsed.content === 'string';
187
- }
188
- catch {
189
- return false;
190
- }
191
- };
192
- const migrateLegacyMemory = async (env) => {
193
- const legacyPath = path.join(readWorkingDirectory(env), '.stratus', MEMORY_FILENAME);
194
- const globalPath = memoryFilePath(env);
195
- if (legacyPath === globalPath) {
196
- return;
197
- }
198
- const legacyDir = path.dirname(legacyPath);
199
- const archivePath = `${legacyPath}.migrated`;
200
- const claimAndImport = async (sourcePath) => {
201
- const claimPath = path.join(legacyDir, `${MEMORY_FILENAME}.migrating-${randomUUID()}`);
202
- try {
203
- await rename(sourcePath, claimPath);
204
- }
205
- catch (error) {
206
- if (error.code === 'ENOENT') {
207
- return; // another process owns it, or there is nothing to migrate
208
- }
209
- throw error;
210
- }
211
- const claimed = await readFile(claimPath, 'utf8');
212
- let existingIds;
213
- try {
214
- existingIds = new Set((await readFile(globalPath, 'utf8'))
215
- .split('\n')
216
- .filter(isMemoryEntryLine)
217
- .map((line) => JSON.parse(line).id));
218
- }
219
- catch {
220
- existingIds = new Set();
221
- }
222
- const entries = claimed
223
- .split('\n')
224
- .filter((line) => line.trim().length > 0)
225
- .filter(isMemoryEntryLine)
226
- .filter((line) => !existingIds.has(JSON.parse(line).id));
227
- if (entries.length > 0) {
228
- await mkdir(path.dirname(globalPath), { recursive: true });
229
- await appendFile(globalPath, `${entries.join('\n')}\n`);
230
- }
231
- // Archive by appending (never overwriting an earlier archive), then
232
- // drop the claim — its content is fully preserved in the archive.
233
- if (claimed.length > 0) {
234
- await appendFile(archivePath, claimed.endsWith('\n') || claimed.length === 0 ? claimed : `${claimed}\n`);
235
- }
236
- await unlink(claimPath);
237
- };
238
- await claimAndImport(legacyPath);
239
- // Finish any claims a crashed run left behind (both the current unique
240
- // names and the fixed .migrating name from earlier versions).
241
- let leftovers;
242
- try {
243
- leftovers = await readdir(legacyDir);
244
- }
245
- catch (error) {
246
- if (error.code === 'ENOENT') {
247
- return;
248
- }
249
- throw error;
250
- }
251
- for (const name of leftovers) {
252
- if (name.startsWith(`${MEMORY_FILENAME}.migrating`)) {
253
- await claimAndImport(path.join(legacyDir, name));
254
- }
255
- }
256
- };
257
- // Agents keep the same memory across CLI runs: every remembered fact lands
258
- // in ~/.stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
259
- // tomorrow — from any directory — is the Ava you talked to today. One JSON
260
- // entry per line, written with O_APPEND: concurrent runs each add their own
261
- // line instead of re-writing the file, so no run can clobber another's
262
- // remembered fact.
263
- export const createFileMemoryStore = (filePath) => {
264
- const readEntries = async () => {
265
- let raw;
266
- try {
267
- raw = await readFile(filePath, 'utf8');
268
- }
269
- catch (error) {
270
- if (error.code === 'ENOENT') {
271
- return [];
272
- }
273
- throw error;
274
- }
275
- return raw
276
- .split('\n')
277
- .filter((line) => line.trim().length > 0)
278
- .map((line) => {
279
- try {
280
- return JSON.parse(line);
281
- }
282
- catch {
283
- throw new Error(`Memory file has an invalid line: ${filePath}`);
284
- }
285
- });
286
- };
287
- return {
288
- async append(agentId, content, metadata) {
289
- const entry = {
290
- id: `${agentId}:memory:${randomUUID()}`,
291
- agentId,
292
- content,
293
- createdAt: new Date().toISOString(),
294
- ...(metadata ? { metadata } : {}),
295
- };
296
- await mkdir(path.dirname(filePath), { recursive: true });
297
- await appendFile(filePath, `${JSON.stringify(entry)}\n`);
298
- return entry;
299
- },
300
- async list(agentId) {
301
- // Dedupe by id at read time: even if a rare race double-imported a
302
- // fact, the agent only ever sees it once.
303
- const seen = new Set();
304
- return (await readEntries()).filter((entry) => {
305
- if (entry.agentId !== agentId || seen.has(entry.id)) {
306
- return false;
307
- }
308
- seen.add(entry.id);
309
- return true;
310
- });
311
- },
312
- };
313
- };
314
161
  const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
315
162
  const readPromptFromStdin = async (stdin) => {
316
163
  stdin.setEncoding('utf8');
@@ -320,54 +167,6 @@ const readPromptFromStdin = async (stdin) => {
320
167
  }
321
168
  return data.trim();
322
169
  };
323
- const parseProviderName = (value, label) => {
324
- if (value === 'demo' || value === 'openai' || value === 'anthropic') {
325
- return value;
326
- }
327
- throw new Error(`Unsupported provider in ${label}: ${value}`);
328
- };
329
- const readProcessEnv = (env) => env.processEnv ?? process.env;
330
- const readWorkingDirectory = (env) => env.cwd ?? process.cwd();
331
- const readHomeDirectory = (env) => env.homeDir ?? os.homedir();
332
- const stratusHomePath = (env) => path.join(readHomeDirectory(env), STRATUS_HOME_DIRNAME);
333
- const globalConfigPath = (env) => path.join(stratusHomePath(env), GLOBAL_CONFIG_FILENAME);
334
- const credentialsPath = (env) => path.join(stratusHomePath(env), CREDENTIALS_FILENAME);
335
- const agentsDirPath = (env) => path.join(stratusHomePath(env), AGENTS_DIRNAME);
336
- const loadCredentials = async (env) => {
337
- let raw;
338
- try {
339
- raw = await readFile(credentialsPath(env), 'utf8');
340
- }
341
- catch (error) {
342
- if (error.code === 'ENOENT') {
343
- return {};
344
- }
345
- throw error;
346
- }
347
- const parsed = JSON.parse(raw);
348
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
349
- throw new Error(`Credentials file must contain a JSON object: ${credentialsPath(env)}`);
350
- }
351
- const credentials = {};
352
- for (const provider of ['anthropic', 'openai']) {
353
- const entry = parsed[provider];
354
- if (typeof entry === 'object' && entry !== null && !Array.isArray(entry) &&
355
- (entry.type === 'api_key' || entry.type === 'oauth_token') &&
356
- typeof entry.value === 'string') {
357
- credentials[provider] = entry;
358
- }
359
- }
360
- return credentials;
361
- };
362
- // Credentials never live in a project directory or a shell profile — they
363
- // are written once by `stratus setup` and read on every run, 0600 so only
364
- // the owner can read them.
365
- const saveCredentials = async (env, credentials) => {
366
- const filePath = credentialsPath(env);
367
- await mkdir(path.dirname(filePath), { recursive: true });
368
- await writeFile(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
369
- await chmod(filePath, 0o600);
370
- };
371
170
  const readOptionValue = (tokens, index, flag) => {
372
171
  const value = tokens[index + 1];
373
172
  if (!value) {
@@ -414,13 +213,257 @@ export const parseCommand = (argv, env = {}) => {
414
213
  }
415
214
  return { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
416
215
  }
216
+ if (command === 'serve') {
217
+ const parsed = { command: 'serve', events: true };
218
+ for (let index = 0; index < rest.length; index += 1) {
219
+ const token = rest[index];
220
+ if (!token) {
221
+ continue;
222
+ }
223
+ if (token === '--help' || token === '-h') {
224
+ return { command: 'help' };
225
+ }
226
+ if (token === '--config') {
227
+ parsed.configPath = readOptionValue(rest, index, '--config');
228
+ index += 1;
229
+ continue;
230
+ }
231
+ if (token === '--idle-timeout') {
232
+ const seconds = Number(readOptionValue(rest, index, '--idle-timeout'));
233
+ if (!Number.isFinite(seconds) || seconds < 0) {
234
+ throw new Error(`Invalid value for --idle-timeout: ${rest[index + 1] ?? '(missing)'}`);
235
+ }
236
+ parsed.idleTimeoutMs = Math.round(seconds * 1000);
237
+ index += 1;
238
+ continue;
239
+ }
240
+ if (token === '--no-events') {
241
+ parsed.events = false;
242
+ continue;
243
+ }
244
+ if (token === '--no-log-file') {
245
+ parsed.logToFile = false;
246
+ continue;
247
+ }
248
+ throw new Error(`Unknown option: ${token}`);
249
+ }
250
+ return parsed;
251
+ }
252
+ if (command === 'chat') {
253
+ const parsed = { command: 'chat', events: false, approvals: 'always' };
254
+ for (let index = 0; index < rest.length; index += 1) {
255
+ const token = rest[index];
256
+ if (!token) {
257
+ continue;
258
+ }
259
+ if (token === '--help' || token === '-h') {
260
+ return { command: 'help' };
261
+ }
262
+ if (token === '--provider') {
263
+ parsed.provider = parseProviderName(readOptionValue(rest, index, '--provider'), '--provider');
264
+ index += 1;
265
+ continue;
266
+ }
267
+ if (token === '--model') {
268
+ parsed.model = readOptionValue(rest, index, '--model');
269
+ index += 1;
270
+ continue;
271
+ }
272
+ if (token === '--base-url') {
273
+ parsed.baseUrl = readOptionValue(rest, index, '--base-url');
274
+ index += 1;
275
+ continue;
276
+ }
277
+ if (token === '--soul') {
278
+ parsed.soul = readOptionValue(rest, index, '--soul');
279
+ index += 1;
280
+ continue;
281
+ }
282
+ if (token === '--config') {
283
+ parsed.configPath = readOptionValue(rest, index, '--config');
284
+ index += 1;
285
+ continue;
286
+ }
287
+ if (token === '--approvals') {
288
+ const value = readOptionValue(rest, index, '--approvals');
289
+ if (value !== 'always' && value !== 'ask' && value !== 'never') {
290
+ throw new Error(`Invalid value for --approvals: ${value}`);
291
+ }
292
+ parsed.approvals = value;
293
+ index += 1;
294
+ continue;
295
+ }
296
+ if (token === '--max-turns') {
297
+ const value = Number(readOptionValue(rest, index, '--max-turns'));
298
+ if (!Number.isInteger(value) || value < 1) {
299
+ throw new Error(`Invalid value for --max-turns: ${rest[index + 1] ?? '(missing)'}`);
300
+ }
301
+ parsed.maxTurns = value;
302
+ index += 1;
303
+ continue;
304
+ }
305
+ if (token === '--events') {
306
+ parsed.events = true;
307
+ continue;
308
+ }
309
+ throw new Error(`Unknown option: ${token}`);
310
+ }
311
+ return parsed;
312
+ }
313
+ if (command === 'agents' || (command === 'agent' && rest[0] === 'list')) {
314
+ const agentsRest = command === 'agents' ? rest : rest.slice(1);
315
+ let format = 'text';
316
+ for (let index = 0; index < agentsRest.length; index += 1) {
317
+ const token = agentsRest[index];
318
+ if (!token) {
319
+ continue;
320
+ }
321
+ if (token === '--help' || token === '-h') {
322
+ return { command: 'help' };
323
+ }
324
+ if (token === '--format') {
325
+ const value = readOptionValue(agentsRest, index, '--format');
326
+ if (value !== 'text' && value !== 'json') {
327
+ throw new Error(`Unsupported format: ${value}`);
328
+ }
329
+ format = value;
330
+ index += 1;
331
+ continue;
332
+ }
333
+ throw new Error(`Unknown option: ${token}`);
334
+ }
335
+ return { command: 'agents', format };
336
+ }
337
+ if (command === 'doctor') {
338
+ let format = 'text';
339
+ let configPath;
340
+ for (let index = 0; index < rest.length; index += 1) {
341
+ const token = rest[index];
342
+ if (!token) {
343
+ continue;
344
+ }
345
+ if (token === '--help' || token === '-h') {
346
+ return { command: 'help' };
347
+ }
348
+ if (token === '--format') {
349
+ const value = readOptionValue(rest, index, '--format');
350
+ if (value !== 'text' && value !== 'json') {
351
+ throw new Error(`Unsupported format: ${value}`);
352
+ }
353
+ format = value;
354
+ index += 1;
355
+ continue;
356
+ }
357
+ if (token === '--config') {
358
+ configPath = readOptionValue(rest, index, '--config');
359
+ index += 1;
360
+ continue;
361
+ }
362
+ throw new Error(`Unknown option: ${token}`);
363
+ }
364
+ return { command: 'doctor', format, ...(configPath ? { configPath } : {}) };
365
+ }
366
+ if (command === 'service') {
367
+ const action = rest[0];
368
+ if (action === '--help' || action === '-h' || action === undefined) {
369
+ return { command: 'help' };
370
+ }
371
+ if (action !== 'install' && action !== 'uninstall' && action !== 'status' && action !== 'start' && action !== 'stop') {
372
+ throw new Error(`Unknown service action: ${action} (expected install, uninstall, status, start, or stop)`);
373
+ }
374
+ const parsed = { command: 'service', action };
375
+ const serviceRest = rest.slice(1);
376
+ for (let index = 0; index < serviceRest.length; index += 1) {
377
+ const token = serviceRest[index];
378
+ if (!token) {
379
+ continue;
380
+ }
381
+ if (token === '--help' || token === '-h') {
382
+ return { command: 'help' };
383
+ }
384
+ if (token === '--no-login') {
385
+ if (action !== 'install') {
386
+ throw new Error('--no-login applies to `stratus service install`.');
387
+ }
388
+ parsed.runAtLogin = false;
389
+ continue;
390
+ }
391
+ if (token === '--config') {
392
+ if (action !== 'install') {
393
+ throw new Error('--config applies to `stratus service install`.');
394
+ }
395
+ parsed.configPath = readOptionValue(serviceRest, index, '--config');
396
+ index += 1;
397
+ continue;
398
+ }
399
+ throw new Error(`Unknown option: ${token}`);
400
+ }
401
+ return parsed;
402
+ }
403
+ if (command === 'logs') {
404
+ let follow = false;
405
+ let limit = 50;
406
+ let agentId;
407
+ let sessionId;
408
+ let format = 'text';
409
+ for (let index = 0; index < rest.length; index += 1) {
410
+ const token = rest[index];
411
+ if (!token) {
412
+ continue;
413
+ }
414
+ if (token === '--help' || token === '-h') {
415
+ return { command: 'help' };
416
+ }
417
+ if (token === '--follow' || token === '-f') {
418
+ follow = true;
419
+ continue;
420
+ }
421
+ if (token === '--lines' || token === '-n') {
422
+ const value = Number(readOptionValue(rest, index, token));
423
+ if (!Number.isFinite(value) || value < 0) {
424
+ throw new Error(`Invalid value for ${token}: ${rest[index + 1] ?? '(missing)'}`);
425
+ }
426
+ limit = Math.floor(value);
427
+ index += 1;
428
+ continue;
429
+ }
430
+ if (token === '--agent') {
431
+ agentId = readOptionValue(rest, index, '--agent');
432
+ index += 1;
433
+ continue;
434
+ }
435
+ if (token === '--session') {
436
+ sessionId = readOptionValue(rest, index, '--session');
437
+ index += 1;
438
+ continue;
439
+ }
440
+ if (token === '--format') {
441
+ const value = readOptionValue(rest, index, '--format');
442
+ if (value !== 'text' && value !== 'json') {
443
+ throw new Error(`Unsupported format: ${value}`);
444
+ }
445
+ format = value;
446
+ index += 1;
447
+ continue;
448
+ }
449
+ throw new Error(`Unknown option: ${token}`);
450
+ }
451
+ return {
452
+ command: 'logs',
453
+ follow,
454
+ limit,
455
+ format,
456
+ ...(agentId ? { agentId } : {}),
457
+ ...(sessionId ? { sessionId } : {}),
458
+ };
459
+ }
417
460
  if (command === 'agent') {
418
461
  const [subcommand, ...agentRest] = rest;
419
462
  if (subcommand === '--help' || subcommand === '-h') {
420
463
  return { command: 'help' };
421
464
  }
422
465
  if (subcommand !== 'new') {
423
- throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new`);
466
+ throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new, stratus agent list`);
424
467
  }
425
468
  let name;
426
469
  let instructions;
@@ -621,359 +664,69 @@ export const formatEvent = (event) => {
621
664
  return null;
622
665
  }
623
666
  };
624
- const loadConfigFile = async (configPath) => {
625
- const raw = await readFile(configPath, 'utf8');
626
- const parsed = JSON.parse(raw);
627
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
628
- throw new Error(`Config file must contain a JSON object: ${configPath}`);
629
- }
630
- const config = parsed;
631
- const resolved = {};
632
- if (typeof config.provider === 'string') {
633
- resolved.provider = parseProviderName(config.provider, `config ${configPath}`);
634
- }
635
- if (typeof config.model === 'string' && config.model.length > 0) {
636
- resolved.model = config.model;
637
- }
638
- if (typeof config.baseUrl === 'string' && config.baseUrl.length > 0) {
639
- resolved.baseUrl = config.baseUrl;
640
- }
641
- if (typeof config.apiKeyEnv === 'string' && config.apiKeyEnv.length > 0) {
642
- resolved.apiKeyEnv = config.apiKeyEnv;
643
- }
644
- if (typeof config.systemPrompt === 'string' && config.systemPrompt.length > 0) {
645
- resolved.systemPrompt = config.systemPrompt;
646
- }
647
- if (typeof config.soul === 'string' && config.soul.length > 0) {
648
- resolved.soul = config.soul;
649
- }
650
- if (typeof config.fallbackModel === 'string' && config.fallbackModel.length > 0) {
651
- resolved.fallbackModel = config.fallbackModel;
652
- }
653
- if (typeof config.fallbackProvider === 'string') {
654
- resolved.fallbackProvider = parseProviderName(config.fallbackProvider, `config ${configPath}`);
655
- }
656
- if (typeof config.fallbackBaseUrl === 'string' && config.fallbackBaseUrl.length > 0) {
657
- resolved.fallbackBaseUrl = config.fallbackBaseUrl;
658
- }
659
- return resolved;
660
- };
661
- const resolveConfigLocation = async (command, env) => {
662
- const processEnv = readProcessEnv(env);
663
- const cwd = readWorkingDirectory(env);
664
- const explicit = command.configPath ?? processEnv.STRATUS_CONFIG ?? processEnv.STRATUSCLAW_CONFIG;
665
- if (explicit) {
666
- return { path: path.resolve(cwd, explicit), trusted: true };
667
- }
668
- // Project-local configs win; the global ~/.stratus/config.json written by
669
- // `stratus setup` is the fallback that makes the CLI work from anywhere.
670
- const candidates = [
671
- { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), trusted: false },
672
- { path: path.join(cwd, LEGACY_CONFIG_FILENAME), trusted: false },
673
- { path: globalConfigPath(env), trusted: true },
674
- ];
675
- for (const candidate of candidates) {
676
- try {
677
- await readFile(candidate.path, 'utf8');
678
- return candidate;
679
- }
680
- catch (error) {
681
- const code = error.code;
682
- if (code !== 'ENOENT') {
683
- throw error;
684
- }
685
- }
686
- }
687
- return undefined;
688
- };
689
- const readNonEmptyString = (value, map) => {
690
- if (typeof value !== 'string') {
691
- return undefined;
692
- }
693
- const trimmed = value.trim();
694
- if (trimmed.length === 0) {
695
- return undefined;
667
+ /**
668
+ * The fields worth keeping per event type. Tool inputs and message text
669
+ * are deliberately excluded: the log is a trace of what happened, not a
670
+ * second copy of the transcript.
671
+ */
672
+ const eventDetail = (event) => {
673
+ switch (event.type) {
674
+ case 'session.updated':
675
+ return { status: event.status };
676
+ case 'provider.response':
677
+ return { parts: event.parts.length };
678
+ case 'tool.called':
679
+ case 'tool.denied':
680
+ return { tool: event.call.toolName };
681
+ case 'tool.completed':
682
+ return { tool: event.result.toolName, ok: event.result.ok };
683
+ case 'session.failed':
684
+ return { error: event.error };
685
+ default:
686
+ return undefined;
696
687
  }
697
- return map ? map(trimmed) : trimmed;
698
688
  };
699
- // A soul travels with the run: --soul outranks STRATUS_SOUL, which outranks
700
- // the config file's "soul" key.
701
- const resolveSoul = async (command, env, fileConfig) => {
702
- const processEnv = readProcessEnv(env);
703
- const soulPath = command.soul
704
- ?? readNonEmptyString(processEnv.STRATUS_SOUL)
705
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
706
- ?? fileConfig.soul;
707
- if (!soulPath) {
708
- return undefined;
709
- }
710
- const resolvedPath = path.resolve(readWorkingDirectory(env), String(soulPath));
711
- let raw;
712
- try {
713
- raw = await readFile(resolvedPath, 'utf8');
714
- }
715
- catch (error) {
716
- if (error.code === 'ENOENT') {
717
- throw new Error(`Soul file not found: ${resolvedPath}`);
718
- }
719
- throw error;
720
- }
721
- try {
722
- // Seeding with the resolved path keeps an unnamed soul's generated
723
- // identity (name, id, avatar) stable across runs — persisted memory is
724
- // keyed by that id, so it must not change between invocations.
725
- return parseSoul(raw, { seed: resolvedPath });
689
+ // Kept with its historical CLI signature: a parsed run command is a
690
+ // RuntimeSelection plus CLI-only fields the resolver ignores.
691
+ export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
692
+ /**
693
+ * A saved subscription sign-in silently demoted to per-token billing is the
694
+ * one config surprise that costs money — an API key in the environment
695
+ * outranks the stored credential, and the run otherwise looks identical.
696
+ * Detected from the resolved config: an apiKey where the stored credential
697
+ * is a subscription token means the environment won.
698
+ */
699
+ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
700
+ if (runtime.provider === 'demo') {
701
+ return;
726
702
  }
727
- catch (error) {
728
- throw new Error(`Could not parse soul file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`);
703
+ const stored = (await loadCredentials(env)).anthropic;
704
+ if (stored?.type !== 'oauth_token') {
705
+ return;
729
706
  }
730
- };
731
- export const resolveRuntimeConfig = async (command, env = {}) => {
707
+ // The resolver records the variable that actually won — guessing it here
708
+ // would name the wrong one whenever a custom apiKeyEnv or the legacy
709
+ // STRATUSCLAW_ prefix supplied the key, sending the reader to unset
710
+ // something that was never the cause.
711
+ const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
712
+ // A fallback demoted the same way costs exactly as much, and only bites
713
+ // once the primary is already failing — the worst moment to discover it.
714
+ // The fallback path consults the provider's own conventional variable.
732
715
  const processEnv = readProcessEnv(env);
733
- const configLocation = await resolveConfigLocation(command, env);
734
- const fileConfig = configLocation ? await loadConfigFile(configLocation.path) : {};
735
- const soul = await resolveSoul(command, env, fileConfig);
736
- // Explicit flags and env vars outrank the soul's own provider/model hints,
737
- // which outrank the config file's defaults.
738
- const provider = command.provider
739
- ?? readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
740
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
741
- ?? readNonEmptyString(soul?.provider, (value) => parseProviderName(value, 'soul file'))
742
- ?? fileConfig.provider
743
- ?? 'demo';
744
- if (provider === 'demo') {
745
- return { provider: 'demo', ...(soul ? { soul } : {}) };
746
- }
747
- // A config file's model/baseUrl/apiKeyEnv were written for the provider
748
- // named in that file. When a flag, env var, or soul selects a different
749
- // provider, those values would point at the wrong API (e.g. an OpenAI
750
- // base URL handed to the Anthropic SDK), so they are ignored. A file with
751
- // no provider key predates the anthropic option, so its settings are
752
- // treated as openai-specific.
753
- const fileConfigApplies = (fileConfig.provider ?? 'openai') === provider;
754
- // A soul's model was chosen for the soul's own provider. If a flag or env
755
- // var overrides that provider, the model hint would target the wrong API
756
- // (e.g. a Claude model sent to OpenAI), so it only applies when the soul
757
- // names no provider or names the selected one.
758
- const soulModelApplies = soul?.provider === undefined || soul.provider === provider;
759
- const model = command.model
760
- ?? readNonEmptyString(processEnv.STRATUS_MODEL)
761
- ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
762
- ?? (soulModelApplies ? readNonEmptyString(soul?.model) : undefined)
763
- ?? (fileConfigApplies ? fileConfig.model : undefined)
764
- ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL);
765
- const apiKeyEnvName = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
766
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
767
- ?? (fileConfigApplies ? fileConfig.apiKeyEnv : undefined)
768
- ?? (provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
769
- const credentials = await loadCredentials(env);
770
- // A custom endpoint chosen by an auto-discovered project config is not a
771
- // place the stored sign-in ever gets sent — a cloned repository could
772
- // point it anywhere. Flags and env vars are the user's own choice, and
773
- // the provider's default endpoint is harmless.
774
- const defaultEndpointFor = (target) => target === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL;
775
- const untrustedCustomBaseUrl = configLocation?.trusted === false
776
- && command.baseUrl === undefined
777
- && readNonEmptyString(processEnv.STRATUS_BASE_URL) === undefined
778
- && readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL) === undefined
779
- && fileConfigApplies
780
- && fileConfig.baseUrl !== undefined
781
- && fileConfig.baseUrl.replace(/\/+$/, '') !== defaultEndpointFor(String(provider));
782
- // Env vars outrank the stored sign-in from `stratus setup`.
783
- const envApiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
784
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
785
- ?? readNonEmptyString(processEnv[String(apiKeyEnvName)]);
786
- const candidateCredential = credentials[provider];
787
- // A bound credential ignores config URLs entirely, so an untrusted
788
- // project URL cannot redirect it — only unbound stored keys are blocked.
789
- const credentialIsBound = candidateCredential?.type === 'api_key' && candidateCredential.baseUrl !== undefined;
790
- const storedCredential = envApiKey || (untrustedCustomBaseUrl && !credentialIsBound)
791
- ? undefined
792
- : candidateCredential;
793
- const apiKey = envApiKey
794
- ?? (storedCredential?.type === 'api_key' ? storedCredential.value : undefined);
795
- const authToken = provider === 'anthropic' && storedCredential?.type === 'oauth_token'
796
- ? storedCredential.value
716
+ const fallbackVar = runtime.fallback?.provider === 'anthropic'
717
+ && runtime.fallback.apiKey !== undefined
718
+ && readNonEmptyString(processEnv.ANTHROPIC_API_KEY) === runtime.fallback.apiKey
719
+ ? 'ANTHROPIC_API_KEY'
797
720
  : undefined;
798
- // A stored key bound to an endpoint is used ONLY with that endpoint — a
799
- // config file can never redirect it, not even to the official default
800
- // URL (a project config could otherwise reroute a local-service key to
801
- // the official API). An explicit flag or env URL that disagrees refuses
802
- // the stored key instead of leaking it. This applies to both providers.
803
- const boundBaseUrl = !envApiKey && storedCredential?.type === 'api_key'
804
- ? storedCredential.baseUrl
805
- : undefined;
806
- const explicitBaseUrl = command.baseUrl
807
- ?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
808
- ?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL);
809
- if (boundBaseUrl && explicitBaseUrl
810
- && String(explicitBaseUrl).replace(/\/+$/, '') !== boundBaseUrl.replace(/\/+$/, '')) {
811
- throw new Error(`Your saved ${provider} sign-in is bound to ${boundBaseUrl} and is not sent to ${explicitBaseUrl}. Set ${apiKeyEnvName} or STRATUS_API_KEY to use that endpoint.`);
812
- }
813
- const baseUrl = boundBaseUrl
814
- ?? explicitBaseUrl
815
- ?? (fileConfigApplies ? fileConfig.baseUrl : undefined)
816
- // The Anthropic SDK knows its own endpoint; only openai needs a default.
817
- ?? (provider === 'anthropic' ? undefined : DEFAULT_OPENAI_BASE_URL);
818
- if (!apiKey && !authToken) {
819
- if (untrustedCustomBaseUrl && credentials[provider]) {
820
- throw new Error(`The project config at ${configLocation?.path} sets a custom base URL (${fileConfig.baseUrl}), so your saved sign-in is not sent to it. Set ${apiKeyEnvName} or STRATUS_API_KEY to use this endpoint, or run with --config to trust the file explicitly.`);
821
- }
822
- throw new Error(`Missing API key for provider=${provider}. Run \`stratus setup\` to sign in, or set STRATUS_API_KEY or ${apiKeyEnvName}.`);
823
- }
824
- const resolved = provider === 'anthropic'
825
- ? {
826
- provider: 'anthropic',
827
- model: String(model),
828
- ...(baseUrl ? { baseUrl: String(baseUrl) } : {}),
829
- ...(apiKey ? { apiKey: String(apiKey) } : {}),
830
- ...(authToken ? { authToken } : {}),
831
- }
832
- : {
833
- provider: 'openai',
834
- model: String(model),
835
- baseUrl: String(baseUrl),
836
- apiKey: String(apiKey),
837
- };
838
- const systemPrompt = readNonEmptyString(processEnv.STRATUS_SYSTEM_PROMPT)
839
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SYSTEM_PROMPT)
840
- ?? fileConfig.systemPrompt;
841
- if (systemPrompt) {
842
- resolved.systemPrompt = String(systemPrompt);
843
- }
844
- if (env.fetch) {
845
- resolved.fetch = env.fetch;
846
- }
847
- if (soul) {
848
- resolved.soul = soul;
849
- }
850
- // A configured fallback model kicks in when the default model errors
851
- // mid-run. It needs its own working sign-in; without one the fallback is
852
- // quietly skipped rather than failing the run it exists to rescue.
853
- // An implicit fallback (no fallbackProvider key) was written for the
854
- // config's own provider — when a flag, env var, or soul overrides that
855
- // provider, the fallback model would target the wrong API, so it is
856
- // ignored. An explicit fallbackProvider stays valid regardless.
857
- if (fileConfig.fallbackModel && (fileConfig.fallbackProvider !== undefined || fileConfigApplies)) {
858
- const fallbackProvider = fileConfig.fallbackProvider ?? provider;
859
- if (fallbackProvider !== 'demo') {
860
- // Same precedence as the primary sign-in: environment keys outrank
861
- // the stored credential. And the same endpoint rule: an untrusted
862
- // project config's custom fallback URL never receives a stored key —
863
- // including the primary's stored key when both share a provider.
864
- // Only env-supplied keys follow such a URL.
865
- const fallbackUntrustedUrl = fallbackProvider === 'openai'
866
- && configLocation?.trusted === false
867
- && fileConfig.fallbackBaseUrl !== undefined
868
- && fileConfig.fallbackBaseUrl.replace(/\/+$/, '') !== DEFAULT_OPENAI_BASE_URL;
869
- const fallbackEnvKey = readNonEmptyString(processEnv[fallbackProvider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY']);
870
- const fallbackCredential = fallbackEnvKey || fallbackUntrustedUrl ? undefined : credentials[fallbackProvider];
871
- const primaryReusable = fallbackProvider === provider
872
- && (envApiKey !== undefined || !fallbackUntrustedUrl);
873
- const fallbackApiKey = (primaryReusable ? apiKey : undefined)
874
- ?? fallbackEnvKey
875
- ?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.value : undefined);
876
- const fallbackAuthToken = fallbackProvider !== provider && fallbackProvider === 'anthropic' && fallbackCredential?.type === 'oauth_token'
877
- ? fallbackCredential.value
878
- : (primaryReusable ? authToken : undefined);
879
- if (fallbackApiKey || fallbackAuthToken) {
880
- // When the fallback key comes out of the credential store (its own
881
- // entry, or the primary's reused stored key), its bound endpoint is
882
- // authoritative — config URLs cannot redirect it.
883
- const fallbackBoundUrl = fallbackCredential?.type === 'api_key'
884
- ? fallbackCredential.baseUrl
885
- : (primaryReusable && !envApiKey ? boundBaseUrl : undefined);
886
- // An anthropic fallback on the same provider keeps the primary's
887
- // configured endpoint — retrying the same credential against the
888
- // official endpoint instead of the configured service would leak
889
- // it and likely fail.
890
- const fallbackAnthropicBaseUrl = fallbackProvider === 'anthropic'
891
- ? (fallbackProvider === provider && baseUrl ? String(baseUrl) : undefined)
892
- ?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.baseUrl : undefined)
893
- : undefined;
894
- resolved.fallback = {
895
- provider: fallbackProvider,
896
- model: fileConfig.fallbackModel,
897
- ...(fallbackProvider === 'openai'
898
- ? {
899
- baseUrl: fallbackBoundUrl
900
- ?? fileConfig.fallbackBaseUrl
901
- ?? (fallbackProvider === provider ? String(baseUrl) : undefined)
902
- ?? DEFAULT_OPENAI_BASE_URL,
903
- }
904
- : (fallbackAnthropicBaseUrl ? { baseUrl: fallbackAnthropicBaseUrl } : {})),
905
- ...(fallbackApiKey ? { apiKey: String(fallbackApiKey) } : {}),
906
- ...(fallbackAuthToken ? { authToken: fallbackAuthToken } : {}),
907
- };
908
- }
909
- }
910
- }
911
- return resolved;
912
- };
913
- // Wraps the fallback runtime as a provider: the primary model serves every
914
- // turn until it throws, then the run switches to the fallback for good.
915
- const createFallbackWrappedProvider = (primary, fallback, onFallback) => {
916
- let usingFallback = false;
917
- return {
918
- name: primary.name,
919
- async generate(request) {
920
- if (!usingFallback) {
921
- try {
922
- return await primary.generate(request);
923
- }
924
- catch (error) {
925
- usingFallback = true;
926
- onFallback(error);
927
- }
928
- }
929
- return fallback.generate(request);
930
- },
931
- };
932
- };
933
- const createRuntimeProvider = (config, onFallback) => {
934
- if (config.provider === 'demo') {
935
- return createDemoProvider();
936
- }
937
- if (config.fallback) {
938
- const { fallback, ...primaryConfig } = config;
939
- const primary = createRuntimeProvider(primaryConfig);
940
- const fallbackProvider = createRuntimeProvider({
941
- ...fallback,
942
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
943
- ...(config.fetch ? { fetch: config.fetch } : {}),
944
- });
945
- return createFallbackWrappedProvider(primary, fallbackProvider, onFallback ?? (() => { }));
946
- }
947
- if (config.provider === 'anthropic') {
948
- // Subscription setup tokens are only honored inside the Claude Code
949
- // harness, so they route through the Agent SDK runtime; API keys use
950
- // the raw Messages API.
951
- if (config.authToken && !config.apiKey) {
952
- return createClaudeCodeProvider({
953
- authToken: config.authToken,
954
- model: config.model,
955
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
956
- });
957
- }
958
- return createAnthropicProvider({
959
- model: config.model,
960
- ...(config.apiKey ? { apiKey: config.apiKey } : {}),
961
- ...(config.authToken ? { authToken: config.authToken } : {}),
962
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
963
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
964
- ...(config.fetch ? { fetch: config.fetch } : {}),
965
- });
721
+ const culprit = primary ?? fallbackVar;
722
+ if (!culprit) {
723
+ return;
966
724
  }
967
- return createOpenAICompatibleProvider({
968
- name: 'openai',
969
- model: config.model,
970
- apiKey: config.apiKey,
971
- baseUrl: config.baseUrl,
972
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
973
- ...(config.fetch ? { fetch: config.fetch } : {}),
974
- });
725
+ writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the Claude subscription sign-in saved by \`stratus setup\`, `
726
+ + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
727
+ + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
975
728
  };
976
- const createApprovalPolicy = (mode, streams, env) => {
729
+ const createApprovalPolicy = (mode, streams, env, ask) => {
977
730
  if (mode === 'always') {
978
731
  return new AllowAllApprovalPolicy();
979
732
  }
@@ -984,6 +737,16 @@ const createApprovalPolicy = (mode, streams, env) => {
984
737
  },
985
738
  };
986
739
  }
740
+ // A caller that already owns stdin (chat's readline) supplies its own
741
+ // asker — two readers on one stream would race for the same bytes.
742
+ if (ask) {
743
+ return {
744
+ async approve({ call }) {
745
+ const answer = await ask(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
746
+ return /^y(es)?$/i.test(answer.trim());
747
+ },
748
+ };
749
+ }
987
750
  return {
988
751
  async approve({ call }) {
989
752
  const input = env.approvalInput ?? process.stdin;
@@ -1004,7 +767,11 @@ const createApprovalPolicy = (mode, streams, env) => {
1004
767
  },
1005
768
  };
1006
769
  };
1007
- export const runSingleLoop = async (prompt, streams, options) => {
770
+ // The shared wiring behind every conversational command: memory (with
771
+ // legacy migration), tools, events, provider (with fallback), and the
772
+ // agent the runtime resolved. run uses it for one shot; chat keeps the
773
+ // runner alive and resumes the same session turn after turn.
774
+ const createAgentRuntime = async (streams, options) => {
1008
775
  const runEnv = options.env ?? {};
1009
776
  await migrateLegacyMemory(runEnv);
1010
777
  const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(runEnv)));
@@ -1016,7 +783,13 @@ export const runSingleLoop = async (prompt, streams, options) => {
1016
783
  writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
1017
784
  },
1018
785
  });
1019
- if (options.events ?? true) {
786
+ if (options.onEvent) {
787
+ const onEvent = options.onEvent;
788
+ bus.subscribe(async (event) => {
789
+ onEvent(event);
790
+ });
791
+ }
792
+ else if (options.events ?? true) {
1020
793
  bus.subscribe(async (event) => {
1021
794
  const line = formatEvent(event);
1022
795
  if (line) {
@@ -1024,40 +797,49 @@ export const runSingleLoop = async (prompt, streams, options) => {
1024
797
  }
1025
798
  });
1026
799
  }
800
+ // The Claude Code runtime executes kernel tools by calling back into the
801
+ // runner built just below — late-bound because the runner needs the
802
+ // provider first.
803
+ let hostedRunner;
1027
804
  const runtimeProvider = createRuntimeProvider(options.runtime, (error) => {
1028
805
  const fallback = options.runtime.provider === 'demo' ? undefined : options.runtime.fallback;
1029
806
  writeLine(streams.stderr, `Warning: the default model failed (${error instanceof Error ? error.message : String(error)}); falling back to ${fallback?.model ?? 'the fallback model'}.`);
1030
- });
807
+ }, async (session, call, context) => {
808
+ if (!hostedRunner) {
809
+ throw new Error('The Stratus runtime is not ready to execute tools yet.');
810
+ }
811
+ return hostedRunner.executeHostedToolCall(session, call, context);
812
+ }, options.maxTurns);
1031
813
  const runner = new AgentRunner({
1032
814
  provider: runtimeProvider,
1033
815
  tools,
1034
816
  executor: createLocalCommandExecutor(),
1035
- approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
817
+ approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
1036
818
  bus,
1037
819
  memory,
1038
820
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
1039
821
  });
822
+ hostedRunner = runner;
1040
823
  await runner.initialize();
1041
824
  // A soul is a full identity — without one, every provider serves the
1042
825
  // same built-in Stratus persona.
1043
826
  const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
1044
827
  const metadata = options.runtime.provider === 'demo'
1045
- ? { provider: 'demo' }
828
+ ? { provider: 'demo', executor: 'local-command' }
1046
829
  : {
1047
830
  provider: options.runtime.provider,
1048
831
  model: options.runtime.model,
1049
832
  ...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
1050
833
  };
834
+ return { runner, agent, metadata };
835
+ };
836
+ export const runSingleLoop = async (prompt, streams, options) => {
837
+ const { runner, agent, metadata } = await createAgentRuntime(streams, options);
1051
838
  return runner.run({
1052
839
  sessionId: randomUUID(),
1053
840
  agent,
1054
841
  userMessage: prompt,
1055
- metadata: options.runtime.provider === 'demo'
1056
- ? {
1057
- ...metadata,
1058
- executor: 'local-command',
1059
- }
1060
- : metadata,
842
+ metadata,
1061
843
  });
1062
844
  };
1063
845
  export const printSessionSummary = (session, streams) => {
@@ -1077,6 +859,220 @@ export const printSessionSummary = (session, streams) => {
1077
859
  writeLine(streams.stdout, `[${message.role}${nameSuffix}] ${content}`);
1078
860
  }
1079
861
  };
862
+ const lastAssistantReply = (session) => {
863
+ for (let index = session.messages.length - 1; index >= 0; index -= 1) {
864
+ const message = session.messages[index];
865
+ if (message && message.role === 'assistant' && message.content.trim().length > 0) {
866
+ return message.content;
867
+ }
868
+ }
869
+ return '(no reply)';
870
+ };
871
+ const CHAT_HELP = [
872
+ ' /help show this',
873
+ ' /exit leave the chat (Ctrl+C and Ctrl+D work too)',
874
+ 'Everything else is a message to your agent. The conversation persists',
875
+ 'across turns, and facts they remember stick forever.',
876
+ ].join('\n');
877
+ export const runChat = async (command, streams, env = {}) => {
878
+ const runtime = await resolveRuntimeConfig({
879
+ command: 'run',
880
+ prompt: '',
881
+ format: 'text',
882
+ events: false,
883
+ approvals: command.approvals,
884
+ ...(command.provider ? { provider: command.provider } : {}),
885
+ ...(command.model ? { model: command.model } : {}),
886
+ ...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
887
+ ...(command.soul ? { soul: command.soul } : {}),
888
+ ...(command.configPath ? { configPath: command.configPath } : {}),
889
+ }, env);
890
+ await warnOnCredentialOverride(runtime, streams, env);
891
+ // Interactive means the real terminal: an injected stdinStream is by
892
+ // definition not a TTY conversation, so it never gets prompts, ANSI
893
+ // styling, or a terminal readline wired to a stream that can't take it.
894
+ const interactive = env.setupInput === undefined
895
+ && env.stdinStream === undefined
896
+ && process.stdin.isTTY === true;
897
+ // Styling is for eyes: piped transcripts stay plain text.
898
+ const bold = (text) => (interactive ? `\u001b[1m${text}\u001b[0m` : text);
899
+ const dim = (text) => (interactive ? `\u001b[2m${text}\u001b[0m` : text);
900
+ const input = env.stdinStream ?? process.stdin;
901
+ const rl = interactive
902
+ ? createInterface({
903
+ input,
904
+ output: streams.stdout,
905
+ prompt: '\u001b[36myou ›\u001b[0m ',
906
+ terminal: true,
907
+ })
908
+ : createInterface({ input, terminal: false });
909
+ // One reader owns stdin. Every line lands in this queue, and whoever is
910
+ // waiting — the chat loop, or an approval question mid-turn — takes the
911
+ // next one. A second readline would race for the same bytes, and
912
+ // rl.question cannot be used while the interface is being iterated.
913
+ const lineQueue = [];
914
+ let inputClosed = false;
915
+ let pendingApproval;
916
+ let notifyLine;
917
+ rl.on('line', (rawLine) => {
918
+ if (pendingApproval) {
919
+ const resolve = pendingApproval;
920
+ pendingApproval = undefined;
921
+ resolve(rawLine);
922
+ return;
923
+ }
924
+ lineQueue.push(rawLine);
925
+ notifyLine?.();
926
+ });
927
+ rl.on('close', () => {
928
+ inputClosed = true;
929
+ if (pendingApproval) {
930
+ const resolve = pendingApproval;
931
+ pendingApproval = undefined;
932
+ resolve('');
933
+ }
934
+ notifyLine?.();
935
+ });
936
+ const nextLine = () => {
937
+ if (lineQueue.length > 0) {
938
+ return Promise.resolve(lineQueue.shift());
939
+ }
940
+ if (inputClosed) {
941
+ return Promise.resolve(undefined);
942
+ }
943
+ return new Promise((resolve) => {
944
+ notifyLine = () => {
945
+ notifyLine = undefined;
946
+ resolve(lineQueue.shift());
947
+ };
948
+ });
949
+ };
950
+ // An approval consumes the next unconsumed line (typed live, or already
951
+ // queued from piped input). A closed stream denies instead of hanging.
952
+ const askApproval = (prompt) => {
953
+ streams.stderr.write(prompt);
954
+ const queued = lineQueue.shift();
955
+ if (queued !== undefined) {
956
+ return Promise.resolve(queued);
957
+ }
958
+ if (inputClosed) {
959
+ return Promise.resolve('');
960
+ }
961
+ return new Promise((resolve) => {
962
+ pendingApproval = resolve;
963
+ });
964
+ };
965
+ const { runner, agent, metadata } = await createAgentRuntime(streams, {
966
+ runtime,
967
+ approvals: command.approvals,
968
+ askApproval,
969
+ ...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
970
+ env,
971
+ onEvent: (event) => {
972
+ if (command.events) {
973
+ const line = formatEvent(event);
974
+ if (line) {
975
+ writeLine(streams.stdout, dim(line));
976
+ }
977
+ return;
978
+ }
979
+ // Quiet by default — just a whisper when the agent reaches for a tool.
980
+ if (event.type === 'tool.called') {
981
+ writeLine(streams.stdout, dim(` · using ${event.call.toolName}`));
982
+ }
983
+ else if (event.type === 'tool.denied') {
984
+ writeLine(streams.stdout, dim(` · ${event.call.toolName} denied`));
985
+ }
986
+ },
987
+ });
988
+ const modelLine = runtime.provider === 'demo'
989
+ ? 'demo (offline)'
990
+ : `${runtime.provider} · ${runtime.model}`;
991
+ if (interactive) {
992
+ streams.stdout.write('\u001b[2J\u001b[H');
993
+ for (const line of stratusHeaderLines()) {
994
+ writeLine(streams.stdout, line);
995
+ }
996
+ writeLine(streams.stdout);
997
+ writeLine(streams.stdout, `Chatting with ${bold(agent.name)} — ${modelLine}.`);
998
+ writeLine(streams.stdout, dim('The conversation persists across turns. /exit to leave, /help for more.'));
999
+ writeLine(streams.stdout);
1000
+ }
1001
+ // Ctrl+C between turns closes the chat gracefully; mid-turn there is
1002
+ // nothing to cancel in the kernel yet, so leave immediately instead of
1003
+ // silently waiting out a slow provider call.
1004
+ let turnInFlight = false;
1005
+ rl.on('SIGINT', () => {
1006
+ if (turnInFlight) {
1007
+ writeLine(streams.stdout);
1008
+ writeLine(streams.stdout, dim('Interrupted.'));
1009
+ process.exit(130);
1010
+ }
1011
+ rl.close();
1012
+ });
1013
+ // One session for the whole sitting: the first message starts it, every
1014
+ // later message resumes it — the same plumbing a channel will use to
1015
+ // keep a thread alive.
1016
+ let sessionId;
1017
+ if (interactive) {
1018
+ rl.prompt();
1019
+ }
1020
+ for (;;) {
1021
+ const rawLine = await nextLine();
1022
+ if (rawLine === undefined) {
1023
+ break;
1024
+ }
1025
+ const line = rawLine.trim();
1026
+ if (line.length === 0) {
1027
+ if (interactive) {
1028
+ rl.prompt();
1029
+ }
1030
+ continue;
1031
+ }
1032
+ if (line === '/exit' || line === '/quit' || line === 'exit' || line === 'quit') {
1033
+ break;
1034
+ }
1035
+ if (line === '/help') {
1036
+ writeLine(streams.stdout, CHAT_HELP);
1037
+ if (interactive) {
1038
+ rl.prompt();
1039
+ }
1040
+ continue;
1041
+ }
1042
+ if (!interactive) {
1043
+ writeLine(streams.stdout, `you › ${line}`);
1044
+ }
1045
+ try {
1046
+ turnInFlight = true;
1047
+ let session;
1048
+ if (sessionId === undefined) {
1049
+ const id = randomUUID();
1050
+ sessionId = id;
1051
+ session = await runner.run({ sessionId: id, agent, userMessage: line, metadata });
1052
+ }
1053
+ else {
1054
+ session = await runner.resume({ sessionId, userMessage: line });
1055
+ }
1056
+ writeLine(streams.stdout, `${bold(`${agent.name} ›`)} ${lastAssistantReply(session)}`);
1057
+ }
1058
+ catch (error) {
1059
+ writeLine(streams.stderr, `Error: ${error instanceof Error ? error.message : String(error)}`);
1060
+ writeLine(streams.stdout, dim('(that turn failed — the conversation is still here, try again)'));
1061
+ }
1062
+ finally {
1063
+ turnInFlight = false;
1064
+ }
1065
+ writeLine(streams.stdout);
1066
+ if (interactive) {
1067
+ rl.prompt();
1068
+ }
1069
+ }
1070
+ rl.close();
1071
+ if (interactive) {
1072
+ writeLine(streams.stdout, dim(`Bye — ${agent.name} keeps what they remembered.`));
1073
+ }
1074
+ return 0;
1075
+ };
1080
1076
  const formatRuntimeBanner = (runtime) => {
1081
1077
  const soulSuffix = runtime.soul ? ` as ${runtime.soul.agent.name}` : '';
1082
1078
  if (runtime.provider === 'demo') {
@@ -1613,6 +1609,94 @@ const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
1613
1609
  return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
1614
1610
  }
1615
1611
  };
1612
+ // Slack verification talks to the Web API over plain fetch rather than
1613
+ // through @stratusagent/channel-slack. Setup must stay usable before that
1614
+ // optional package is installed, and the CLI deliberately does not depend
1615
+ // on it (or on the ~9 MB of Slack SDKs underneath).
1616
+ const SLACK_API_ROOT = 'https://slack.com/api';
1617
+ const callSlack = async (method, token, fetchImpl) => {
1618
+ if (typeof fetchImpl !== 'function') {
1619
+ return { ok: false, verdict: { status: 'unreachable', detail: 'fetch is unavailable' } };
1620
+ }
1621
+ try {
1622
+ const response = await fetchImpl(`${SLACK_API_ROOT}/${method}`, {
1623
+ method: 'POST',
1624
+ headers: {
1625
+ authorization: `Bearer ${token}`,
1626
+ 'content-type': 'application/x-www-form-urlencoded',
1627
+ },
1628
+ });
1629
+ if (!response.ok) {
1630
+ return { ok: false, verdict: { status: 'unreachable', detail: `HTTP ${response.status}` } };
1631
+ }
1632
+ const body = await response.json();
1633
+ if (body.ok === true) {
1634
+ return { ok: true, body };
1635
+ }
1636
+ // Slack answers 200 with { ok: false, error } for bad credentials, so
1637
+ // the error string — not the status — is what condemns a token.
1638
+ return { ok: false, verdict: { status: 'rejected', detail: String(body.error ?? 'unknown error') } };
1639
+ }
1640
+ catch (error) {
1641
+ return { ok: false, verdict: { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) } };
1642
+ }
1643
+ };
1644
+ /** Verifies a bot token and reports the identity it belongs to. */
1645
+ const verifySlackBotToken = async (token, fetchImpl) => {
1646
+ const result = await callSlack('auth.test', token, fetchImpl);
1647
+ if (!result.ok) {
1648
+ return result.verdict;
1649
+ }
1650
+ return {
1651
+ status: 'ok',
1652
+ identity: {
1653
+ ...(typeof result.body.user_id === 'string' ? { botUserId: result.body.user_id } : {}),
1654
+ ...(typeof result.body.team === 'string' ? { teamName: result.body.team } : {}),
1655
+ ...(typeof result.body.team_id === 'string' ? { teamId: result.body.team_id } : {}),
1656
+ },
1657
+ };
1658
+ };
1659
+ /**
1660
+ * Verifies an app-level token by opening a Socket Mode URL. The URL is
1661
+ * discarded — this only proves the token carries connections:write, which
1662
+ * is the failure the daemon would otherwise hit at start time.
1663
+ */
1664
+ const verifySlackAppToken = async (token, fetchImpl) => {
1665
+ const result = await callSlack('apps.connections.open', token, fetchImpl);
1666
+ return result.ok ? { status: 'ok', identity: {} } : result.verdict;
1667
+ };
1668
+ // Scopes and events the adapter needs. Kept in step with the manifest
1669
+ // shipped by @stratusagent/channel-slack (a test pins them together) so
1670
+ // setup can hand over a ready-to-paste manifest without depending on that
1671
+ // package being installed.
1672
+ const SLACK_BOT_SCOPES = [
1673
+ 'app_mentions:read',
1674
+ 'chat:write',
1675
+ 'files:write',
1676
+ 'im:history',
1677
+ 'im:read',
1678
+ 'im:write',
1679
+ 'users:read',
1680
+ ];
1681
+ const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
1682
+ export const slackAppManifest = (agentName) => JSON.stringify({
1683
+ display_information: {
1684
+ name: agentName,
1685
+ description: 'A Stratus agent',
1686
+ background_color: '#1a1d21',
1687
+ },
1688
+ features: {
1689
+ bot_user: { display_name: agentName, always_online: true },
1690
+ },
1691
+ oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
1692
+ settings: {
1693
+ event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
1694
+ interactivity: { is_enabled: false },
1695
+ org_deploy_enabled: false,
1696
+ socket_mode_enabled: true,
1697
+ token_rotation_enabled: false,
1698
+ },
1699
+ }, null, 2);
1616
1700
  const DEFAULT_SOUL_STARTER = [
1617
1701
  'You are a helpful, warm generalist. Answer first, explain second, and',
1618
1702
  'keep replies short unless the question genuinely needs depth. Use',
@@ -1650,6 +1734,12 @@ export const runSetup = async (command, streams, env = {}) => {
1650
1734
  // A path passed via --config is not auto-discovered by `stratus run`, so
1651
1735
  // suggested commands must carry it explicitly.
1652
1736
  const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
1737
+ // Set once save() detects a project config shadowing the global one.
1738
+ // Suggested commands must carry it too, or they read a different config
1739
+ // than the one just written — for `serve` that means a different roster,
1740
+ // and Slack apps stored here would be skipped as having no agent.
1741
+ let shadowConfigFlag = '';
1742
+ const serveCommand = () => `stratus serve${runConfigFlag}${shadowConfigFlag}`;
1653
1743
  // Seed from what is already configured, so re-running setup edits instead
1654
1744
  // of clobbering.
1655
1745
  let existing = {};
@@ -1702,6 +1792,24 @@ export const runSetup = async (command, streams, env = {}) => {
1702
1792
  ...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
1703
1793
  credentials: await loadCredentials(env),
1704
1794
  credentialsDirty: false,
1795
+ channels: await loadChannelCredentials(env),
1796
+ channelsDirty: false,
1797
+ // On by default: setup's whole promise is that you finish it and the
1798
+ // agents are working. An always-on runtime you have to remember to
1799
+ // start is not always-on, and every Slack app configured here stays
1800
+ // silent until stratusd is up. An existing install keeps ITS login
1801
+ // setting, though — rerunning setup and pressing save must not quietly
1802
+ // undo a deliberate `service install --no-login`.
1803
+ service: await (async () => {
1804
+ const status = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
1805
+ // An unknown answer (a broken user bus, a timed-out query) must not
1806
+ // read as "they chose --no-login" — that would disable a service on
1807
+ // the next save because of a transient failure.
1808
+ return {
1809
+ install: true,
1810
+ runAtLogin: status?.installed && status.runAtLogin !== undefined ? status.runAtLogin : true,
1811
+ };
1812
+ })(),
1705
1813
  };
1706
1814
  const prompter = createSetupPrompter(baseStreams, env, {
1707
1815
  header: stratusHeaderLines,
@@ -1768,6 +1876,13 @@ export const runSetup = async (command, streams, env = {}) => {
1768
1876
  }
1769
1877
  return state.soulPath;
1770
1878
  };
1879
+ const channelsSummary = () => {
1880
+ const connected = Object.keys(state.channels.slack ?? {}).length;
1881
+ if (connected === 0) {
1882
+ return 'Slack: not connected';
1883
+ }
1884
+ return `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
1885
+ };
1771
1886
  const storeCredential = (provider, credential) => {
1772
1887
  state.credentials[provider] = credential;
1773
1888
  state.credentialsDirty = true;
@@ -2287,18 +2402,267 @@ export const runSetup = async (command, streams, env = {}) => {
2287
2402
  writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export OPENAI_API_KEY).');
2288
2403
  return undefined;
2289
2404
  }
2290
- const fallback = buildTestFallback();
2291
- return {
2292
- provider: 'openai',
2293
- model,
2294
- baseUrl: boundUrl ?? state.baseUrl ?? DEFAULT_OPENAI_BASE_URL,
2295
- apiKey,
2296
- ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
2297
- ...(env.fetch ? { fetch: env.fetch } : {}),
2298
- ...(soul ? { soul } : {}),
2299
- ...(fallback ? { fallback } : {}),
2405
+ const fallback = buildTestFallback();
2406
+ return {
2407
+ provider: 'openai',
2408
+ model,
2409
+ baseUrl: boundUrl ?? state.baseUrl ?? DEFAULT_OPENAI_BASE_URL,
2410
+ apiKey,
2411
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
2412
+ ...(env.fetch ? { fetch: env.fetch } : {}),
2413
+ ...(soul ? { soul } : {}),
2414
+ ...(fallback ? { fallback } : {}),
2415
+ };
2416
+ };
2417
+ /**
2418
+ * Walks an agent through connecting their own Slack app: show the
2419
+ * manifest to paste, take both tokens, verify each against Slack, and
2420
+ * store them under the agent's id. Tokens are keyed by a roster agent
2421
+ * picked from a list rather than typed, so the id can never drift from
2422
+ * the roster — the mismatch that otherwise surfaces only as a startup
2423
+ * warning about an agent the gateway cannot find.
2424
+ */
2425
+ const connectSlackAgent = async (agentId, agentName) => {
2426
+ writeLine(streams.stdout);
2427
+ writeLine(streams.stdout, `Create a Slack app for ${agentName}:`);
2428
+ writeLine(streams.stdout, ' 1. Open https://api.slack.com/apps → Create New App → From a manifest');
2429
+ writeLine(streams.stdout, ' 2. Pick your workspace, then paste this manifest:');
2430
+ writeLine(streams.stdout);
2431
+ for (const line of slackAppManifest(agentName).split('\n')) {
2432
+ writeLine(streams.stdout, ` ${line}`);
2433
+ }
2434
+ writeLine(streams.stdout);
2435
+ writeLine(streams.stdout, ' 3. Create the app, then Basic Information → App-Level Tokens →');
2436
+ writeLine(streams.stdout, ' Generate a token with the connections:write scope (xapp-…)');
2437
+ writeLine(streams.stdout, ' 4. Install App → copy the Bot User OAuth Token (xoxb-…)');
2438
+ writeLine(streams.stdout, ` 5. Basic Information → Display Information → upload ${agentName}'s avatar`);
2439
+ writeLine(streams.stdout);
2440
+ const appToken = await prompter.askSecret('Paste the app-level token (xapp-…, Enter to cancel; input is hidden): ');
2441
+ if (!appToken) {
2442
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
2443
+ return;
2444
+ }
2445
+ if (!appToken.startsWith('xapp-')) {
2446
+ writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-). Nothing was saved.');
2447
+ return;
2448
+ }
2449
+ const botToken = await prompter.askSecret('Paste the bot user OAuth token (xoxb-…, Enter to cancel; input is hidden): ');
2450
+ if (!botToken) {
2451
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
2452
+ return;
2453
+ }
2454
+ if (!botToken.startsWith('xoxb-')) {
2455
+ writeLine(streams.stdout, '✗ That does not look like a bot token (they start with xoxb-). Nothing was saved.');
2456
+ return;
2457
+ }
2458
+ writeLine(streams.stdout, 'Checking the tokens with Slack…');
2459
+ const fetchImpl = env.fetch ?? globalThis.fetch;
2460
+ const bot = await verifySlackBotToken(botToken, fetchImpl);
2461
+ if (bot.status === 'rejected') {
2462
+ writeLine(streams.stdout, `✗ Slack rejected the bot token (${bot.detail}). Nothing was saved — reinstall the app and copy the token again.`);
2463
+ return;
2464
+ }
2465
+ const app = await verifySlackAppToken(appToken, fetchImpl);
2466
+ if (app.status === 'rejected') {
2467
+ writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — check it has the connections:write scope.`);
2468
+ return;
2469
+ }
2470
+ const slack = { ...(state.channels.slack ?? {}) };
2471
+ slack[agentId] = { appToken, botToken };
2472
+ state.channels = { ...state.channels, slack };
2473
+ state.channelsDirty = true;
2474
+ if (bot.status === 'ok' && app.status === 'ok') {
2475
+ const where = bot.identity.teamName ? ` in ${bot.identity.teamName}` : '';
2476
+ const who = bot.identity.botUserId ? ` (bot ${bot.identity.botUserId})` : '';
2477
+ writeLine(streams.stdout, `✓ Verified — ${agentName} is connected to Slack${where}${who}.`);
2478
+ }
2479
+ else {
2480
+ // Unreachable is not a verdict on the tokens: save and let the
2481
+ // daemon report on its first connection attempt.
2482
+ const detail = bot.status === 'unreachable' ? bot.detail : app.detail;
2483
+ writeLine(streams.stdout, `! Could not reach Slack to verify (${detail}). Saved the tokens anyway — \`${serveCommand()}\` will report on startup.`);
2484
+ }
2485
+ };
2486
+ /**
2487
+ * Every agent the gateway would dispatch to: the built-in Stratus
2488
+ * agent, the ~/.stratus/agents roster, and the configured default soul,
2489
+ * which `stratus setup` can point at a file anywhere. The gateway
2490
+ * registers all three, so a Channels list built from the agents
2491
+ * directory alone would hide agents Slack can perfectly well talk to.
2492
+ * Reads state.soulPath rather than the saved config so a soul chosen
2493
+ * earlier in this same setup session is already connectable.
2494
+ */
2495
+ const channelRoster = async () => {
2496
+ const warnOnce = (message) => writeLine(streams.stderr, `Warning: ${message}.`);
2497
+ // Seeded before the roster loads, exactly as loadRoster does it: a
2498
+ // fresh install with no soul files still has an agent to put on
2499
+ // Slack, and a roster file that declares id "stratus" cannot take the
2500
+ // built-in's place — the gateway skips it, so offering it here would
2501
+ // name an app after an agent that never receives the messages.
2502
+ const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
2503
+ // Mirror the gateway's loadRoster: two soul files declaring the same
2504
+ // id are not two agents. Offering both would let someone create and
2505
+ // name an app for the loser, which can never receive a message —
2506
+ // Slack credentials are keyed by the shared id, and dispatch reaches
2507
+ // the first sorted file.
2508
+ for (const entry of await loadRosterSouls(env, warnOnce)) {
2509
+ const clash = entries.find((seen) => seen.soul.agent.id === entry.soul.agent.id);
2510
+ if (clash) {
2511
+ warnOnce(`duplicate agent id ${entry.soul.agent.id} (${clash.path ?? 'built-in'} vs ${entry.path}); keeping the first`);
2512
+ continue;
2513
+ }
2514
+ entries.push(entry);
2515
+ }
2516
+ // The soul a run resolves to, in resolveSoulPath's own order: an env
2517
+ // override outranks the config value this setup session is editing.
2518
+ // Listing the config soul while `stratus serve` registers the env one
2519
+ // would store tokens against an id the adapter then skips.
2520
+ const processEnv = readProcessEnv(env);
2521
+ const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
2522
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
2523
+ if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
2524
+ warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
2525
+ }
2526
+ const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
2527
+ if (!effectiveSoul) {
2528
+ return entries;
2529
+ }
2530
+ const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
2531
+ if (entries.some((entry) => entry.path === resolved)) {
2532
+ return entries;
2533
+ }
2534
+ try {
2535
+ const soul = await loadSoulFile(resolved);
2536
+ // An explicit id can collide with a roster file, or with the
2537
+ // built-in. The gateway's defaultAgentId replaces the registered
2538
+ // source whenever it is pathless (the built-in) or resolves to a
2539
+ // different file, so the configured soul is the one Slack actually
2540
+ // dispatches to — offering the namesake here would connect an app
2541
+ // to a different agent than it names.
2542
+ const collision = entries.findIndex((entry) => entry.soul.agent.id === soul.agent.id);
2543
+ if (collision >= 0) {
2544
+ entries.splice(collision, 1);
2545
+ }
2546
+ entries.unshift({ soul, path: resolved });
2547
+ }
2548
+ catch (error) {
2549
+ warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
2550
+ }
2551
+ return entries;
2552
+ };
2553
+ const serviceSummary = () => {
2554
+ if (!servicePlatform(serviceEnvFor(env))) {
2555
+ return `not available on ${env.processEnv?.OSTYPE ?? process.platform} — run \`stratus serve\` yourself`;
2556
+ }
2557
+ if (!state.service.install) {
2558
+ return 'off — start stratusd yourself with `stratus serve`';
2559
+ }
2560
+ return state.service.runAtLogin
2561
+ ? 'stratusd runs after setup, and at every login'
2562
+ : 'stratusd runs after setup, but not at login';
2563
+ };
2564
+ /**
2565
+ * Whether the roster keeps answering once this terminal closes. On by
2566
+ * default: an agent you have to remember to start is not always-on, and
2567
+ * every Slack app configured above is silent until stratusd runs.
2568
+ */
2569
+ const chooseService = async () => {
2570
+ if (!servicePlatform(serviceEnvFor(env))) {
2571
+ writeLine(streams.stdout);
2572
+ writeLine(streams.stdout, `Stratus has no service integration for ${process.platform} yet — run \`stratus serve\` yourself, or supervise it however you prefer.`);
2573
+ await prompter.ask('Press Enter to return to the menu… ');
2574
+ return;
2575
+ }
2576
+ const choice = await prompter.select('Always on — keep stratusd running in the background', [
2577
+ 'Run after setup, and start again at every login (recommended)',
2578
+ 'Run after setup, but do not start at login',
2579
+ 'Do not run it for me — I will start `stratus serve` myself',
2580
+ 'Back',
2581
+ ], {
2582
+ footnote: process.platform === 'darwin'
2583
+ // Said here rather than left to be discovered after a reboot.
2584
+ ? 'A LaunchAgent starts at login, not at power-on. For a machine that should recover unattended, turn on automatic login too.'
2585
+ : 'A user service starts at login. `loginctl enable-linger` keeps it up on a machine you do not stay logged in to.',
2586
+ });
2587
+ if (choice.kind !== 'index' || choice.index === 3) {
2588
+ return;
2589
+ }
2590
+ state.service = {
2591
+ install: choice.index !== 2,
2592
+ runAtLogin: choice.index === 0,
2300
2593
  };
2301
2594
  };
2595
+ const chooseChannels = async () => {
2596
+ while (true) {
2597
+ const roster = await channelRoster();
2598
+ const slack = state.channels.slack ?? {};
2599
+ // The roster always holds at least the built-in agent, so there is
2600
+ // no empty state to short-circuit on — and short-circuiting would
2601
+ // strand orphaned tokens, which are only reachable from this list.
2602
+ const options = roster.map((entry) => {
2603
+ const id = entry.soul.agent.id;
2604
+ const status = slack[id] ? '✓ connected' : '— not connected';
2605
+ return `${entry.soul.agent.name} (${id})`.padEnd(34) + status;
2606
+ });
2607
+ // Orphans: tokens whose agent left the roster would otherwise be
2608
+ // invisible here while still being loaded by `stratus serve`.
2609
+ const orphans = Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id));
2610
+ for (const id of orphans) {
2611
+ options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
2612
+ }
2613
+ options.push('Back');
2614
+ const choice = await prompter.select('Channels — Slack (one app per agent: its own name, avatar, and presence)', options, { footnote: `Run \`${serveCommand()}\` afterwards to bring the connected agents online.` });
2615
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
2616
+ return;
2617
+ }
2618
+ if (choice.index >= roster.length) {
2619
+ // An orphaned entry — offer to clear it.
2620
+ const orphanId = orphans[choice.index - roster.length];
2621
+ if (!orphanId) {
2622
+ return;
2623
+ }
2624
+ const confirm = await prompter.select(`No agent with id ${orphanId} is in the roster.`, [
2625
+ 'Remove these Slack tokens',
2626
+ 'Keep them',
2627
+ ]);
2628
+ if (confirm.kind === 'index' && confirm.index === 0) {
2629
+ const next = { ...slack };
2630
+ delete next[orphanId];
2631
+ state.channels = { ...state.channels, slack: next };
2632
+ state.channelsDirty = true;
2633
+ writeLine(streams.stdout, `Removed the Slack tokens for ${orphanId}.`);
2634
+ }
2635
+ continue;
2636
+ }
2637
+ const entry = roster[choice.index];
2638
+ if (!entry) {
2639
+ return;
2640
+ }
2641
+ const agentId = entry.soul.agent.id;
2642
+ const agentName = entry.soul.agent.name;
2643
+ if (!slack[agentId]) {
2644
+ await connectSlackAgent(agentId, agentName);
2645
+ continue;
2646
+ }
2647
+ const action = await prompter.select(`${agentName} is connected to Slack.`, [
2648
+ 'Replace the tokens (re-run the app setup)',
2649
+ 'Disconnect from Slack (forget the tokens)',
2650
+ 'Back',
2651
+ ]);
2652
+ if (action.kind !== 'index' || action.index === 2) {
2653
+ continue;
2654
+ }
2655
+ if (action.index === 0) {
2656
+ await connectSlackAgent(agentId, agentName);
2657
+ continue;
2658
+ }
2659
+ const next = { ...slack };
2660
+ delete next[agentId];
2661
+ state.channels = { ...state.channels, slack: next };
2662
+ state.channelsDirty = true;
2663
+ writeLine(streams.stdout, `${agentName} is no longer connected to Slack. The Slack app itself still exists — delete it at api.slack.com/apps if you are done with it.`);
2664
+ }
2665
+ };
2302
2666
  const testRun = async () => {
2303
2667
  const runtime = await buildTestRuntime();
2304
2668
  if (!runtime) {
@@ -2348,7 +2712,6 @@ export const runSetup = async (command, streams, env = {}) => {
2348
2712
  };
2349
2713
  };
2350
2714
  const save = async () => {
2351
- let shadowConfigFlag = '';
2352
2715
  const config = { provider: state.provider };
2353
2716
  if (state.provider !== 'demo') {
2354
2717
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -2405,6 +2768,66 @@ export const runSetup = async (command, streams, env = {}) => {
2405
2768
  await saveCredentials(env, state.credentials);
2406
2769
  writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
2407
2770
  }
2771
+ if (state.channelsDirty) {
2772
+ // Channel tokens live in their own namespace of the same file; the
2773
+ // writers merge, so this never clobbers the provider sign-in above.
2774
+ await saveChannelCredentials(env, state.channels);
2775
+ const connected = Object.keys(state.channels.slack ?? {}).length;
2776
+ writeLine(streams.stdout, connected > 0
2777
+ ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2778
+ : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2779
+ }
2780
+ // Last, so the daemon starts against the config and credentials that
2781
+ // were just written rather than the ones it would have found a moment
2782
+ // ago. A service failure is reported and never fails setup: the
2783
+ // settings are already saved, and `stratus serve` still works by hand.
2784
+ if (!state.service.install && servicePlatform(serviceEnvFor(env))) {
2785
+ // Opting out has to actually take effect. Skipping the install would
2786
+ // leave a unit from an earlier setup running and enabled at login,
2787
+ // while the menu said "off" — still burning provider usage and still
2788
+ // answering in Slack after an explicit opt-out.
2789
+ const existing = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
2790
+ if (existing?.installed) {
2791
+ // Removal deletes the unit file, so it can reject the same way the
2792
+ // install can. Setup's settings are already written by this point;
2793
+ // the optional service must not take the whole command down.
2794
+ const removed = await uninstallService(serviceEnvFor(env)).catch((error) => ({
2795
+ ok: false,
2796
+ messages: [
2797
+ `Could not remove the always-on service: ${error instanceof Error ? error.message : String(error)}`,
2798
+ `${serviceUnitPath(serviceEnvFor(env))} is still in place — remove it by hand, or it will start again at login.`,
2799
+ ],
2800
+ }));
2801
+ for (const message of removed.messages) {
2802
+ writeLine(removed.ok ? streams.stdout : streams.stderr, message);
2803
+ }
2804
+ }
2805
+ }
2806
+ else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
2807
+ // The unit is pinned to the file setup just wrote. Its working
2808
+ // directory is the home directory, so discovery from there would
2809
+ // find a different config whenever setup was run with --config or
2810
+ // STRATUS_CONFIG — the daemon would come up on another roster and
2811
+ // leave the Slack apps configured above offline.
2812
+ // installService writes files, so it can reject outright — an
2813
+ // inaccessible ~/Library/LaunchAgents, a read-only home. Letting
2814
+ // that escape would fail setup itself, after the config and
2815
+ // credentials were already saved, when the always-on service is the
2816
+ // one optional part of it.
2817
+ const result = await installService(serviceEnvFor(env), {
2818
+ runAtLogin: state.service.runAtLogin,
2819
+ configPath,
2820
+ }).catch((error) => ({
2821
+ ok: false,
2822
+ messages: [`Could not install the always-on service: ${error instanceof Error ? error.message : String(error)}`],
2823
+ }));
2824
+ for (const message of result.messages) {
2825
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
2826
+ }
2827
+ if (!result.ok) {
2828
+ writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
2829
+ }
2830
+ }
2408
2831
  writeLine(streams.stdout);
2409
2832
  // Exported STRATUS_* variables outrank the config file, so warn when one
2410
2833
  // would make `stratus run` behave differently from what was just saved.
@@ -2466,11 +2889,13 @@ export const runSetup = async (command, streams, env = {}) => {
2466
2889
  `Providers ${providersSummary()}`,
2467
2890
  `Models ${modelsSummary()}`,
2468
2891
  `Agent ${agentSummary()}`,
2892
+ `Channels ${channelsSummary()}`,
2893
+ `Always on ${serviceSummary()}`,
2469
2894
  'Test run say hello with the current settings',
2470
2895
  'Save & finish',
2471
2896
  ]);
2472
2897
  // Backing out of the top level (Esc, or the input ending) saves.
2473
- if (choice.kind !== 'index' || choice.index === 4) {
2898
+ if (choice.kind !== 'index' || choice.index === 6) {
2474
2899
  break;
2475
2900
  }
2476
2901
  if (choice.index === 0) {
@@ -2483,6 +2908,12 @@ export const runSetup = async (command, streams, env = {}) => {
2483
2908
  await chooseAgent();
2484
2909
  }
2485
2910
  else if (choice.index === 3) {
2911
+ await chooseChannels();
2912
+ }
2913
+ else if (choice.index === 4) {
2914
+ await chooseService();
2915
+ }
2916
+ else if (choice.index === 5) {
2486
2917
  await testRun();
2487
2918
  }
2488
2919
  }
@@ -2493,6 +2924,663 @@ export const runSetup = async (command, streams, env = {}) => {
2493
2924
  prompter.close();
2494
2925
  }
2495
2926
  };
2927
+ const personaSnippet = (instructions) => {
2928
+ const firstLine = instructions?.split('\n').map((line) => line.trim()).find((line) => line.length > 0);
2929
+ if (!firstLine) {
2930
+ return undefined;
2931
+ }
2932
+ return firstLine.length > 78 ? `${firstLine.slice(0, 77)}…` : firstLine;
2933
+ };
2934
+ export const runAgents = async (command, streams, env = {}) => {
2935
+ // Fold any legacy per-directory memory in first so the counts below
2936
+ // reflect everything each agent actually remembers.
2937
+ await migrateLegacyMemory(env);
2938
+ const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
2939
+ const processEnv = readProcessEnv(env);
2940
+ // Listing must never be blocked by a broken config — it only feeds the
2941
+ // default marker and the "runs on" lines.
2942
+ const { config: activeConfig } = await discoverActiveConfig(env, (message) => {
2943
+ writeLine(streams.stderr, `Warning: ${message}.`);
2944
+ });
2945
+ const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
2946
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'));
2947
+ const envModel = readNonEmptyString(processEnv.STRATUS_MODEL)
2948
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL);
2949
+ // What a run as this soul would actually use right now — the same
2950
+ // precedence as resolveRuntimeConfig (env vars, soul hints, config,
2951
+ // demo), so a model-only or provider-only pin still resolves honestly.
2952
+ const runsOnFor = (soulProvider, soulModel) => {
2953
+ const provider = envProvider ?? soulProvider ?? activeConfig.provider ?? 'demo';
2954
+ if (provider === 'demo') {
2955
+ return { provider };
2956
+ }
2957
+ const soulModelApplies = soulProvider === undefined || soulProvider === provider;
2958
+ const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
2959
+ const model = envModel
2960
+ ?? (soulModelApplies ? soulModel : undefined)
2961
+ ?? (configModelApplies ? activeConfig.model : undefined)
2962
+ ?? (provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
2963
+ return { provider, model };
2964
+ };
2965
+ const defaultSoulPath = readNonEmptyString(processEnv.STRATUS_SOUL)
2966
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
2967
+ ?? activeConfig.soul;
2968
+ const resolvedDefaultSoul = defaultSoulPath
2969
+ ? path.resolve(readWorkingDirectory(env), defaultSoulPath)
2970
+ : undefined;
2971
+ const listings = [];
2972
+ const addSoul = async (soulPath) => {
2973
+ let parsed;
2974
+ try {
2975
+ parsed = parseSoul(await readFile(soulPath, 'utf8'), { seed: soulPath });
2976
+ }
2977
+ catch (error) {
2978
+ writeLine(streams.stderr, `Warning: skipping ${soulPath}: ${error instanceof Error ? error.message : String(error)}`);
2979
+ return;
2980
+ }
2981
+ const { agent } = parsed;
2982
+ const persona = personaSnippet(agent.instructions);
2983
+ listings.push({
2984
+ id: agent.id,
2985
+ name: agent.name,
2986
+ isDefault: soulPath === resolvedDefaultSoul,
2987
+ builtIn: false,
2988
+ soulPath,
2989
+ ...(parsed.provider ? { provider: parsed.provider } : {}),
2990
+ ...(parsed.model ? { model: parsed.model } : {}),
2991
+ runsOn: runsOnFor(parsed.provider, parsed.model),
2992
+ memories: (await memory.list(agent.id)).length,
2993
+ ...(persona ? { persona } : {}),
2994
+ ...(agent.avatar ? { avatar: `${agent.avatar.style} theme, hue ${agent.avatar.hue}, palette ${agent.avatar.palette.join(' ')}` } : {}),
2995
+ });
2996
+ };
2997
+ let rosterFiles = [];
2998
+ try {
2999
+ rosterFiles = (await readdir(agentsDirPath(env)))
3000
+ .filter((file) => file.endsWith('.md'))
3001
+ .sort()
3002
+ .map((file) => path.join(agentsDirPath(env), file));
3003
+ }
3004
+ catch (error) {
3005
+ if (error.code !== 'ENOENT') {
3006
+ throw error;
3007
+ }
3008
+ }
3009
+ for (const soulPath of rosterFiles) {
3010
+ await addSoul(soulPath);
3011
+ }
3012
+ // A default soul can live outside ~/.stratus/agents (a project soul, a
3013
+ // hand-written file) — the roster would be lying without it.
3014
+ if (resolvedDefaultSoul && !rosterFiles.includes(resolvedDefaultSoul)) {
3015
+ await addSoul(resolvedDefaultSoul);
3016
+ }
3017
+ // The built-in Stratus persona serves every run that has no soul.
3018
+ const builtInPersona = personaSnippet(DEFAULT_STRATUS_AGENT.instructions);
3019
+ listings.push({
3020
+ id: DEFAULT_STRATUS_AGENT.id,
3021
+ name: DEFAULT_STRATUS_AGENT.name,
3022
+ isDefault: resolvedDefaultSoul === undefined,
3023
+ builtIn: true,
3024
+ runsOn: runsOnFor(),
3025
+ memories: (await memory.list(DEFAULT_STRATUS_AGENT.id)).length,
3026
+ ...(builtInPersona ? { persona: builtInPersona } : {}),
3027
+ });
3028
+ listings.sort((a, b) => {
3029
+ if (a.isDefault !== b.isDefault) {
3030
+ return a.isDefault ? -1 : 1;
3031
+ }
3032
+ if (a.builtIn !== b.builtIn) {
3033
+ return a.builtIn ? 1 : -1;
3034
+ }
3035
+ return a.name.localeCompare(b.name);
3036
+ });
3037
+ if (command.format === 'json') {
3038
+ writeLine(streams.stdout, JSON.stringify({
3039
+ agents: listings.map(({ isDefault, ...rest }) => ({ ...rest, default: isDefault })),
3040
+ }, null, 2));
3041
+ return 0;
3042
+ }
3043
+ const describeRunsOn = (runsOn) => (runsOn.provider === 'demo' ? 'demo (offline)' : `${runsOn.provider}${runsOn.model ? ` · ${runsOn.model}` : ''}`);
3044
+ writeLine(streams.stdout, 'Agents');
3045
+ for (const agent of listings) {
3046
+ const labels = [
3047
+ ...(agent.isDefault ? ['default'] : []),
3048
+ ...(agent.builtIn ? ['built-in'] : []),
3049
+ ];
3050
+ writeLine(streams.stdout);
3051
+ writeLine(streams.stdout, ` ${agent.name}${labels.length > 0 ? ` (${labels.join(', ')})` : ''}`);
3052
+ writeLine(streams.stdout, ` id ${agent.id}`);
3053
+ if (agent.soulPath) {
3054
+ writeLine(streams.stdout, ` soul ${agent.soulPath}`);
3055
+ }
3056
+ writeLine(streams.stdout, ` runs on ${agent.provider ? describeRunsOn(agent.runsOn) : `follows your setup — currently ${describeRunsOn(agent.runsOn)}`}`);
3057
+ writeLine(streams.stdout, ` memory ${agent.memories === 0 ? 'nothing yet' : `${agent.memories} remembered fact${agent.memories === 1 ? '' : 's'}`}`);
3058
+ if (agent.persona) {
3059
+ writeLine(streams.stdout, ` persona ${agent.persona}`);
3060
+ }
3061
+ if (agent.avatar) {
3062
+ writeLine(streams.stdout, ` avatar ${agent.avatar}`);
3063
+ }
3064
+ }
3065
+ writeLine(streams.stdout);
3066
+ writeLine(streams.stdout, listings.length === 1
3067
+ ? 'That is just the built-in default — create your own with: stratus agent new'
3068
+ : 'Talk to the default with stratus run, or to anyone with stratus run --soul <file>.');
3069
+ return 0;
3070
+ };
3071
+ /**
3072
+ * `stratus doctor` — what a run would resolve to right now, and which file
3073
+ * or variable decided each part. Every "why is it using X" question needs
3074
+ * the precedence chain, and reading it out of the source is not something
3075
+ * anyone should have to do.
3076
+ */
3077
+ export const collectDoctorReport = async (command, warn, env = {}) => {
3078
+ const processEnv = readProcessEnv(env);
3079
+ const cwd = readWorkingDirectory(env);
3080
+ const problems = [];
3081
+ // Config discovery, spelled out rather than delegated: doctor has to
3082
+ // report the files that LOST as well as the one in use, which is the
3083
+ // whole point when a stray project config is the answer.
3084
+ // Which of the three named it, not just that one did: telling someone to
3085
+ // fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
3086
+ // real override in place — the same mistake as provider and key
3087
+ // attribution, in the one place a typo is most likely.
3088
+ const explicitSource = command.configPath !== undefined
3089
+ ? { name: '--config', value: command.configPath }
3090
+ : readNonEmptyString(processEnv.STRATUS_CONFIG)
3091
+ ? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
3092
+ : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
3093
+ ? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
3094
+ : undefined;
3095
+ const explicit = explicitSource?.value;
3096
+ const candidates = explicitSource
3097
+ ? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
3098
+ : [
3099
+ { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
3100
+ { path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
3101
+ { path: globalConfigPath(env), label: 'global' },
3102
+ ];
3103
+ const present = [];
3104
+ let unreadable;
3105
+ for (const candidate of candidates) {
3106
+ try {
3107
+ await readFile(candidate.path, 'utf8');
3108
+ present.push(candidate);
3109
+ }
3110
+ catch (error) {
3111
+ const code = error.code;
3112
+ if (code !== 'ENOENT') {
3113
+ // resolveConfigLocation throws here rather than trying the next
3114
+ // candidate, so discovery stops: a lower-priority file that a real
3115
+ // run never reaches must not be reported as the config in use.
3116
+ unreadable = candidate;
3117
+ problems.push(`${candidate.path} exists but cannot be read (${error.message}). Every run fails here — discovery never reaches a lower-priority config.`);
3118
+ break;
3119
+ }
3120
+ if (explicit) {
3121
+ // Discovery treats a missing candidate as "try the next one", but
3122
+ // an explicitly named config has no next one: a real run calls
3123
+ // loadConfigFile on it and fails. Reporting built-in defaults here
3124
+ // would describe a run that cannot happen, and hide the typo.
3125
+ problems.push(`${candidate.path} does not exist, but ${candidate.label} names it — every run with this setting fails. `
3126
+ + 'Fix the path, or drop the setting to fall back to config discovery.');
3127
+ }
3128
+ }
3129
+ }
3130
+ const winner = unreadable ?? present[0];
3131
+ const shadowed = unreadable ? [] : present.slice(1);
3132
+ if (winner && shadowed.length > 0 && winner.label.startsWith('project')) {
3133
+ problems.push(`${winner.path} outranks ${shadowed.map((entry) => entry.path).join(' and ')} for runs started in this directory. `
3134
+ + 'Run from elsewhere, or pass --config to pick one explicitly.');
3135
+ }
3136
+ let fileConfig = {};
3137
+ let configFatal = unreadable !== undefined;
3138
+ if (winner && !unreadable) {
3139
+ try {
3140
+ fileConfig = await loadConfigFile(winner.path);
3141
+ }
3142
+ catch (error) {
3143
+ // resolveRuntimeConfig propagates this — no run reaches provider or
3144
+ // credential resolution, so presenting defaults as resolved settings
3145
+ // would describe a run that cannot happen.
3146
+ configFatal = true;
3147
+ problems.push(`${winner.path} could not be parsed (${error.message}). Every run fails until it is fixed or removed.`);
3148
+ warn(`ignoring unreadable config ${winner.path}`);
3149
+ }
3150
+ }
3151
+ // Which variable supplied a value, not just the value: naming the wrong
3152
+ // one sends the reader to unset something that was never the cause,
3153
+ // leaving the real override in place. The legacy STRATUSCLAW_ prefix is
3154
+ // still honored by the resolver, so it has to be reportable too.
3155
+ const envPick = (...names) => {
3156
+ for (const name of names) {
3157
+ const value = readNonEmptyString(processEnv[name]);
3158
+ if (typeof value === 'string') {
3159
+ return { name, value };
3160
+ }
3161
+ }
3162
+ return undefined;
3163
+ };
3164
+ /**
3165
+ * The verdict comes from the resolver, not from a second copy of its
3166
+ * rules. Everything below only *attributes* values to the file or
3167
+ * variable that supplied them — which the resolver does not report —
3168
+ * while whether a run works at all, and what it bills, is whatever
3169
+ * resolveRuntimeConfig actually returns or throws.
3170
+ */
3171
+ const envSoul = envPick('STRATUS_SOUL', 'STRATUSCLAW_SOUL');
3172
+ const soulValue = envSoul?.value ?? fileConfig.soul;
3173
+ const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
3174
+ const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
3175
+ // Loaded here for ATTRIBUTION only — the soul's frontmatter is what
3176
+ // explains a provider nobody wrote in a config file. Whether a run works
3177
+ // is still the resolver's verdict below.
3178
+ const soul = soulPath ? await loadSoulFile(soulPath).catch(() => undefined) : undefined;
3179
+ let resolved;
3180
+ if (!configFatal) {
3181
+ try {
3182
+ resolved = await resolveStateRuntimeConfig(command.configPath ? { configPath: command.configPath } : {}, env);
3183
+ }
3184
+ catch (error) {
3185
+ const message = error instanceof Error ? error.message : String(error);
3186
+ problems.push(`No run can start: ${message}`);
3187
+ if (soulPath && /soul/i.test(message)) {
3188
+ // Worth saying explicitly: a soul that fails to load is fatal, not
3189
+ // a quiet downgrade to the built-in agent.
3190
+ problems.push(`Every run fails until ${soulPath} is fixed or the soul setting is removed — the built-in agent is not substituted.`);
3191
+ }
3192
+ }
3193
+ }
3194
+ const envProviderPick = envPick('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER');
3195
+ const providerSource = envProviderPick
3196
+ ? envProviderPick.name
3197
+ : soul?.provider
3198
+ ? `${soulPath} (soul frontmatter)`
3199
+ : fileConfig.provider
3200
+ ? (winner?.path ?? 'config')
3201
+ : 'built-in default — nothing set a provider';
3202
+ // When resolution fails the intended provider is still worth reporting:
3203
+ // "why is it anthropic" is exactly the question, and the problems below
3204
+ // already say no run can start.
3205
+ const intendedProvider = envProviderPick?.value ?? soul?.provider ?? fileConfig.provider ?? 'demo';
3206
+ const provider = resolved
3207
+ ? { value: resolved.provider, source: providerSource }
3208
+ : { value: `${intendedProvider} (unreachable)`, source: providerSource };
3209
+ if (provider.value === 'demo') {
3210
+ // Setup writes the config file, which is the LOWEST precedence of the
3211
+ // three — so when an env var or a soul chose demo, "run setup" is
3212
+ // advice that changes nothing and leaves every run on demo.
3213
+ const fix = envProviderPick
3214
+ ? `Unset ${envProviderPick.name}; it outranks both your config and any soul.`
3215
+ : soul?.provider === 'demo'
3216
+ ? `Change or remove the provider pin in ${soulPath}; a soul outranks the config file.`
3217
+ : fileConfig.provider === 'demo'
3218
+ ? `Change "provider" in ${winner?.path}, or run \`stratus setup\` → Providers.`
3219
+ : 'Run `stratus setup` → Providers and sign in, then Save & finish.';
3220
+ problems.push(`Provider is the offline demo model, so replies are canned. ${fix}`);
3221
+ }
3222
+ let model;
3223
+ if (resolved && resolved.provider !== 'demo') {
3224
+ const envModel = envPick('STRATUS_MODEL', 'STRATUSCLAW_MODEL');
3225
+ // A soul's model belongs to the soul's provider, and the config's model
3226
+ // to the config's provider — either can be stranded by an override.
3227
+ const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
3228
+ const configModelApplies = (fileConfig.provider ?? 'openai') === resolved.provider;
3229
+ model = {
3230
+ value: resolved.model,
3231
+ source: envModel
3232
+ ? envModel.name
3233
+ : soulModelApplies && soul?.model === resolved.model
3234
+ ? `${soulPath} (soul frontmatter)`
3235
+ : configModelApplies && fileConfig.model === resolved.model
3236
+ ? (winner?.path ?? 'config')
3237
+ : 'built-in default for this provider',
3238
+ };
3239
+ }
3240
+ const credentials = await loadCredentials(env);
3241
+ const signIns = [];
3242
+ const usedKeyEnvVar = resolved && resolved.provider !== 'demo' ? resolved.apiKeyEnvVar : undefined;
3243
+ const usesAuthToken = resolved?.provider === 'anthropic' && resolved.authToken !== undefined;
3244
+ const fallback = resolved && resolved.provider !== 'demo' ? resolved.fallback : undefined;
3245
+ for (const target of ['anthropic', 'openai']) {
3246
+ const stored = credentials[target];
3247
+ const label = stored?.type === 'oauth_token' ? 'Claude subscription (Pro/Max)' : 'API key';
3248
+ const isDefault = resolved?.provider === target;
3249
+ const isFallback = fallback?.provider === target;
3250
+ // A provider no resolved run consults is reported, never diagnosed:
3251
+ // flagging an override on a credential nothing reads is a false alarm.
3252
+ if (!isDefault && !isFallback) {
3253
+ signIns.push({
3254
+ provider: target,
3255
+ status: stored
3256
+ ? `${label} (unused — ${resolved ? resolved.provider : 'nothing'} serves your runs)`
3257
+ : 'not signed in',
3258
+ });
3259
+ continue;
3260
+ }
3261
+ const envVar = isDefault ? usedKeyEnvVar : (target === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY');
3262
+ const viaEnv = isDefault
3263
+ ? usedKeyEnvVar !== undefined
3264
+ : fallback?.apiKey !== undefined && fallback.apiKey === readNonEmptyString(processEnv[String(envVar)]);
3265
+ const where = isDefault ? 'runs are' : 'fallback runs are';
3266
+ if (stored && viaEnv) {
3267
+ signIns.push({ provider: target, status: `${label}, overridden by ${envVar} in your environment` });
3268
+ // The costly case: an env key silently demotes a subscription to
3269
+ // per-token billing, and nothing in a normal run says so.
3270
+ problems.push(stored.type === 'oauth_token'
3271
+ ? `${envVar} in your environment outranks your saved ${target} subscription sign-in, so ${where} billed per token instead of through your plan. Unset it (check your shell profile) to use the subscription.`
3272
+ : `${envVar} in your environment outranks your saved ${target} sign-in. Unset it to use the one \`stratus setup\` stored.`);
3273
+ continue;
3274
+ }
3275
+ if (viaEnv) {
3276
+ signIns.push({ provider: target, status: `using ${envVar} from your environment` });
3277
+ continue;
3278
+ }
3279
+ if (stored) {
3280
+ signIns.push({ provider: target, status: isDefault && usesAuthToken ? `${label} — runs go through the Claude Code runtime` : label });
3281
+ continue;
3282
+ }
3283
+ signIns.push({ provider: target, status: 'not signed in' });
3284
+ }
3285
+ // A fallback the resolver dropped is the quiet failure: the config still
3286
+ // names one, but a failing primary has nothing to retry on.
3287
+ if (fileConfig.fallbackModel !== undefined && resolved && resolved.provider !== 'demo' && !fallback) {
3288
+ problems.push(`A fallback model (${fileConfig.fallbackModel}) is configured but could not be resolved — usually no sign-in for its provider, or an endpoint its saved key is not sent to. `
3289
+ + 'A failing primary model has nothing to retry on.');
3290
+ }
3291
+ const channels = await loadChannelCredentials(env);
3292
+ const slackAgents = Object.keys(channels.slack ?? {}).sort();
3293
+ const slackPackageInstalled = (await loadSlackAdapter()) !== undefined;
3294
+ if (slackAgents.length > 0 && !slackPackageInstalled) {
3295
+ problems.push(`Slack tokens are stored for ${slackAgents.length} agent(s) but @stratusagent/channel-slack is not installed, so \`stratus serve\` skips them. `
3296
+ + 'Install it with: npm install -g @stratusagent/channel-slack');
3297
+ }
3298
+ let rosterCount = 0;
3299
+ try {
3300
+ rosterCount = (await readdir(agentsDirPath(env))).filter((file) => file.endsWith('.md')).length;
3301
+ }
3302
+ catch (error) {
3303
+ if (error.code !== 'ENOENT') {
3304
+ throw error;
3305
+ }
3306
+ }
3307
+ // Tokens keyed to an agent the roster no longer has are loaded and
3308
+ // skipped on every start, silently.
3309
+ const rosterIds = new Set((await loadRosterSouls(env, warn)).map((entry) => entry.soul.agent.id));
3310
+ rosterIds.add(DEFAULT_STRATUS_AGENT.id);
3311
+ if (soul) {
3312
+ rosterIds.add(soul.agent.id);
3313
+ }
3314
+ const orphans = slackAgents.filter((id) => !rosterIds.has(id));
3315
+ if (orphans.length > 0) {
3316
+ problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
3317
+ + 'Clear them from `stratus setup` → Channels.');
3318
+ }
3319
+ return {
3320
+ ...(winner ? { configInUse: winner.path } : {}),
3321
+ configShadowed: shadowed.map((entry) => entry.path),
3322
+ provider,
3323
+ ...(model ? { model } : {}),
3324
+ ...(soulPath ? { soul: { value: soulPath, source: soulSource } } : {}),
3325
+ ...(soul ? { agent: `${soul.agent.name} (${soul.agent.id})` } : {}),
3326
+ ...(fallback ? { fallback: { provider: fallback.provider, model: fallback.model } } : {}),
3327
+ signIns,
3328
+ slackAgents,
3329
+ slackPackageInstalled,
3330
+ rosterCount,
3331
+ problems,
3332
+ };
3333
+ };
3334
+ export const runDoctor = async (command, streams, env = {}) => {
3335
+ const report = await collectDoctorReport(command, (message) => {
3336
+ writeLine(streams.stderr, `Warning: ${message}.`);
3337
+ }, env);
3338
+ if (command.format === 'json') {
3339
+ writeLine(streams.stdout, JSON.stringify(report, null, 2));
3340
+ return report.problems.length > 0 ? 1 : 0;
3341
+ }
3342
+ const field = (label, setting) => {
3343
+ if (!setting) {
3344
+ return;
3345
+ }
3346
+ writeLine(streams.stdout, ` ${label.padEnd(10)}${setting.value}`);
3347
+ writeLine(streams.stdout, ` ${''.padEnd(10)}from ${setting.source}`);
3348
+ };
3349
+ writeLine(streams.stdout, 'Stratus Agent — what a run would use right now');
3350
+ writeLine(streams.stdout);
3351
+ field('provider', report.provider);
3352
+ field('model', report.model);
3353
+ field('soul', report.soul);
3354
+ if (report.agent) {
3355
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${report.agent}`);
3356
+ }
3357
+ else if (report.soul) {
3358
+ // A soul is configured but did not load — saying "built-in" here would
3359
+ // describe a run that cannot happen.
3360
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}unresolved — the configured soul could not be read`);
3361
+ }
3362
+ else {
3363
+ writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${DEFAULT_STRATUS_AGENT.name} (built-in — no soul configured)`);
3364
+ }
3365
+ writeLine(streams.stdout);
3366
+ writeLine(streams.stdout, 'Files');
3367
+ writeLine(streams.stdout, ` config ${report.configInUse ?? 'none found — built-in defaults apply'}`);
3368
+ for (const shadowedPath of report.configShadowed) {
3369
+ writeLine(streams.stdout, ` (${shadowedPath} exists but is outranked)`);
3370
+ }
3371
+ writeLine(streams.stdout, ` agents ${report.rosterCount} soul file${report.rosterCount === 1 ? '' : 's'}`);
3372
+ if (report.fallback) {
3373
+ writeLine(streams.stdout, ` ${'fallback'.padEnd(10)}${report.fallback.provider} · ${report.fallback.model}`);
3374
+ writeLine(streams.stdout, ` ${''.padEnd(10)}used when the default model errors mid-run`);
3375
+ }
3376
+ writeLine(streams.stdout);
3377
+ writeLine(streams.stdout, 'Sign-ins');
3378
+ for (const entry of report.signIns) {
3379
+ writeLine(streams.stdout, ` ${entry.provider.padEnd(10)}${entry.status}`);
3380
+ }
3381
+ writeLine(streams.stdout);
3382
+ writeLine(streams.stdout, 'Channels');
3383
+ writeLine(streams.stdout, ` slack ${report.slackAgents.length === 0
3384
+ ? 'no agents connected'
3385
+ : `${report.slackAgents.length} connected (${report.slackAgents.join(', ')})`}`);
3386
+ writeLine(streams.stdout, ` @stratusagent/channel-slack ${report.slackPackageInstalled ? 'installed' : 'not installed'}`);
3387
+ writeLine(streams.stdout);
3388
+ if (report.problems.length === 0) {
3389
+ writeLine(streams.stdout, 'No problems found.');
3390
+ return 0;
3391
+ }
3392
+ writeLine(streams.stdout, `${report.problems.length} problem${report.problems.length === 1 ? '' : 's'} found:`);
3393
+ for (const problem of report.problems) {
3394
+ writeLine(streams.stdout, ` ! ${problem}`);
3395
+ }
3396
+ return 1;
3397
+ };
3398
+ /**
3399
+ * `stratus logs` — the daemon's structured log, filtered. `serve` streams
3400
+ * to its own stdout, which is gone the moment it runs under a service
3401
+ * manager; this reads the file it also writes, from any terminal.
3402
+ */
3403
+ export const runLogs = async (command, streams, env = {}) => {
3404
+ const dir = logsDirPath(env);
3405
+ const filter = {
3406
+ ...(command.agentId ? { agentId: command.agentId } : {}),
3407
+ ...(command.sessionId ? { sessionId: command.sessionId } : {}),
3408
+ };
3409
+ const emit = (record) => {
3410
+ writeLine(streams.stdout, command.format === 'json' ? JSON.stringify(record) : formatLogRecord(record));
3411
+ };
3412
+ // Captured BEFORE the backlog is read: the daemon keeps writing while a
3413
+ // large backlog prints, and a follower that takes its offset afterwards
3414
+ // skips everything written in between — permanently.
3415
+ const followFrom = command.follow ? await currentLogPosition(dir) : undefined;
3416
+ // Bounded by the same offset the follower resumes from: without it, a
3417
+ // record written between the two reads is printed by the backlog and
3418
+ // then again by the stream.
3419
+ const recent = await readRecentRecords(dir, command.limit, filter, followFrom);
3420
+ for (const record of recent) {
3421
+ emit(record);
3422
+ }
3423
+ if (!command.follow) {
3424
+ if (recent.length === 0 && command.format === 'text') {
3425
+ writeLine(streams.stdout, `No log records yet in ${dir}. Start the daemon with \`stratus serve\`.`);
3426
+ }
3427
+ return 0;
3428
+ }
3429
+ const controller = new AbortController();
3430
+ const stop = () => controller.abort();
3431
+ process.once('SIGINT', stop);
3432
+ process.once('SIGTERM', stop);
3433
+ env.shutdownSignal?.addEventListener('abort', stop, { once: true });
3434
+ if (env.shutdownSignal?.aborted) {
3435
+ stop();
3436
+ }
3437
+ try {
3438
+ await tailLog({
3439
+ dir,
3440
+ filter,
3441
+ ...(followFrom !== undefined ? { startPosition: followFrom } : {}),
3442
+ signal: controller.signal,
3443
+ onRecord: emit,
3444
+ });
3445
+ }
3446
+ finally {
3447
+ process.off('SIGINT', stop);
3448
+ process.off('SIGTERM', stop);
3449
+ }
3450
+ return 0;
3451
+ };
3452
+ /** Builds the service view of the CLI environment (home, exec paths, runner). */
3453
+ const serviceEnvFor = (env) => ({
3454
+ ...(env.homeDir !== undefined ? { homeDir: env.homeDir } : {}),
3455
+ cwd: readWorkingDirectory(env),
3456
+ ...(env.serviceRunner !== undefined ? { run: env.serviceRunner } : {}),
3457
+ });
3458
+ /**
3459
+ * A credential that exists only in this shell cannot reach the daemon: a
3460
+ * service manager starts with its own environment and never sources a
3461
+ * profile, so the unit would come up unauthenticated while setup had just
3462
+ * reported everything ready. Returns the variable to name, if so.
3463
+ */
3464
+ /**
3465
+ * Every runtime the daemon would resolve: the config-wide default plus one
3466
+ * per roster soul, normalized the way a dispatch normalizes it. A soul that
3467
+ * pins its own provider resolves to different credentials entirely, so any
3468
+ * check that looks only at the default misses exactly the agent that is
3469
+ * misconfigured.
3470
+ */
3471
+ const servedRuntimes = async (env, configPath) => {
3472
+ const { applySoulPins } = await import('@stratusagent/gateway');
3473
+ const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
3474
+ const context = {
3475
+ ...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
3476
+ configPresent: location !== undefined,
3477
+ };
3478
+ const passes = [{ selection: {}, env }];
3479
+ for (const entry of await loadRosterSouls(env, () => { })) {
3480
+ const normalized = applySoulPins(entry.soul, {}, env, context);
3481
+ passes.push({ selection: normalized.selection, env: normalized.env });
3482
+ }
3483
+ const resolved = [];
3484
+ for (const pass of passes) {
3485
+ // A runtime that cannot resolve is the gateway's to report, per
3486
+ // dispatch and with far better context than a startup pass has.
3487
+ const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
3488
+ if (runtime) {
3489
+ resolved.push({ runtime, env: pass.env });
3490
+ }
3491
+ }
3492
+ return resolved;
3493
+ };
3494
+ /**
3495
+ * `stratus service` — run the daemon under launchd or systemd, so it
3496
+ * survives logout, crashes, and reboots. `serve` itself stays a plain
3497
+ * foreground process; this only tells the platform how to keep it up.
3498
+ */
3499
+ export const runService = async (command, streams, env = {}) => {
3500
+ const serviceEnv = serviceEnvFor(env);
3501
+ if (command.action === 'status') {
3502
+ const status = await readServiceStatus(serviceEnv);
3503
+ if (!status) {
3504
+ writeLine(streams.stdout, `No service manager for ${process.platform}. Run \`stratus serve\` yourself.`);
3505
+ return 1;
3506
+ }
3507
+ writeLine(streams.stdout, `stratusd ${status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3508
+ writeLine(streams.stdout, ` manager ${status.platform}`);
3509
+ writeLine(streams.stdout, ` unit ${status.unitPath}`);
3510
+ if (status.detail) {
3511
+ writeLine(streams.stdout, ` note ${status.detail}`);
3512
+ }
3513
+ if (status.installed) {
3514
+ writeLine(streams.stdout, ` at login ${status.runAtLogin === undefined
3515
+ ? 'unknown — the service manager did not answer'
3516
+ : status.runAtLogin ? 'yes' : 'no'}`);
3517
+ }
3518
+ writeLine(streams.stdout, status.installed
3519
+ ? ' logs stratus logs -f'
3520
+ : ' install stratus service install');
3521
+ return status.running ? 0 : 1;
3522
+ }
3523
+ // A service manager passes none of this shell's environment on, so a
3524
+ // config selected by STRATUS_CONFIG has to be baked into the unit
3525
+ // exactly as --config is. Without it the daemon rediscovers from the
3526
+ // install directory and can come up on a different roster entirely.
3527
+ const processEnv = readProcessEnv(env);
3528
+ const selectedConfig = command.configPath
3529
+ ?? readNonEmptyString(processEnv.STRATUS_CONFIG)
3530
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
3531
+ if (command.action === 'install') {
3532
+ // A config the daemon cannot parse kills it during gateway.start(),
3533
+ // and the manager — having accepted the start — restarts it on a
3534
+ // loop. Better to refuse now, while there is someone reading stderr.
3535
+ // Without an explicit selection the unit carries no --config flag and
3536
+ // discovers from its working directory — the same directory this is
3537
+ // running in — so the discovered file has to be validated too, not
3538
+ // just an explicitly named one.
3539
+ let configToCheck;
3540
+ if (selectedConfig) {
3541
+ configToCheck = path.resolve(readWorkingDirectory(env), String(selectedConfig));
3542
+ }
3543
+ else {
3544
+ try {
3545
+ configToCheck = (await resolveConfigLocation({}, env))?.path;
3546
+ }
3547
+ catch (error) {
3548
+ // Discovery throws when a candidate exists but cannot be read.
3549
+ // Treating that as "no config" would install a daemon that hits
3550
+ // the same error on its first dispatch.
3551
+ writeLine(streams.stderr, `Not installing: ${error instanceof Error ? error.message : String(error)}`);
3552
+ writeLine(streams.stderr, 'The daemon would fail the same way on startup. Fix the file, or move it aside.');
3553
+ return 1;
3554
+ }
3555
+ }
3556
+ if (configToCheck) {
3557
+ try {
3558
+ await loadConfigFile(configToCheck);
3559
+ }
3560
+ catch (error) {
3561
+ writeLine(streams.stderr, `Not installing: ${configToCheck} cannot be used (${error instanceof Error ? error.message : String(error)}).`);
3562
+ writeLine(streams.stderr, 'The daemon would exit on startup and be restarted in a loop. Fix the file, or move it aside.');
3563
+ return 1;
3564
+ }
3565
+ }
3566
+ }
3567
+ const action = command.action === 'install'
3568
+ ? installService(serviceEnv, {
3569
+ ...(command.runAtLogin === false ? { runAtLogin: false } : {}),
3570
+ // Absolute: resolved against the directory the install ran in.
3571
+ ...(selectedConfig ? { configPath: path.resolve(readWorkingDirectory(env), String(selectedConfig)) } : {}),
3572
+ })
3573
+ : command.action === 'uninstall'
3574
+ ? uninstallService(serviceEnv)
3575
+ : command.action === 'start'
3576
+ ? startService(serviceEnv)
3577
+ : stopService(serviceEnv);
3578
+ const result = await action;
3579
+ for (const message of result.messages) {
3580
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
3581
+ }
3582
+ return result.ok ? 0 : 1;
3583
+ };
2496
3584
  export const runAgentNew = async (command, streams, env = {}) => {
2497
3585
  // On a real terminal, creating an agent is the same guided experience as
2498
3586
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -2524,18 +3612,11 @@ export const runAgentNew = async (command, streams, env = {}) => {
2524
3612
  // at all: the soul keeps following the machine's configuration
2525
3613
  // instead of demanding credentials nothing has signed in for.
2526
3614
  const processEnv = readProcessEnv(env);
2527
- const configLocation = await resolveConfigLocation({}, env);
2528
3615
  // Creating an agent must not be blocked by a broken config — it only
2529
3616
  // feeds the soul's provider/model hint, so fall back to defaults.
2530
- let activeConfig = {};
2531
- if (configLocation) {
2532
- try {
2533
- activeConfig = await loadConfigFile(configLocation.path);
2534
- }
2535
- catch (error) {
2536
- writeLine(streams.stdout, `Note: ignoring unreadable config ${configLocation.path} (${error instanceof Error ? error.message : String(error)}).`);
2537
- }
2538
- }
3617
+ const { location: configLocation, config: activeConfig } = await discoverActiveConfig(env, (message) => {
3618
+ writeLine(streams.stdout, `Note: ${message}.`);
3619
+ });
2539
3620
  const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
2540
3621
  ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
2541
3622
  ?? activeConfig.provider
@@ -2667,6 +3748,271 @@ export const runDashboard = async (command, streams, env = {}) => {
2667
3748
  await handle.close();
2668
3749
  return 0;
2669
3750
  };
3751
+ /**
3752
+ * Loads the optional Slack channel package, or undefined when it is not
3753
+ * installed. Only that package being absent is tolerated: an adapter that
3754
+ * fails to load for any other reason (a broken install, a bad transitive
3755
+ * dependency) surfaces rather than silently disabling Slack for a daemon
3756
+ * whose stored tokens say it should be running.
3757
+ */
3758
+ const loadSlackAdapter = async () => {
3759
+ // Resolution and loading are separate questions, and only the first one
3760
+ // means "not installed". Inspecting the import's error message cannot
3761
+ // tell them apart: a package that IS installed but is missing one of
3762
+ // its own dependencies throws ERR_MODULE_NOT_FOUND naming that
3763
+ // dependency and this package as the importer — a broken install would
3764
+ // read as an absent one, silently disabling a channel whose tokens say
3765
+ // it should be running.
3766
+ try {
3767
+ import.meta.resolve('@stratusagent/channel-slack');
3768
+ }
3769
+ catch {
3770
+ return undefined;
3771
+ }
3772
+ // Resolvable: any failure from here is a real problem with the
3773
+ // installed package, and surfaces.
3774
+ return (await import('@stratusagent/channel-slack')).createSlackChannelAdapter;
3775
+ };
3776
+ /**
3777
+ * `stratus serve` — run the gateway (stratusd) in the foreground: load the
3778
+ * roster, accept dispatches, print events, and drain cleanly on SIGTERM,
3779
+ * SIGINT, or the injected shutdown signal.
3780
+ */
3781
+ export const runServe = async (command, streams, env = {}) => {
3782
+ // First, before anything that can throw. A broken install makes the
3783
+ // gateway import below fail, the manager restarts, and the CLI's error
3784
+ // and help text append to the redirect file again — the crash loop this
3785
+ // bounding exists for. Running it after the import would skip exactly
3786
+ // that case.
3787
+ if (command.logToFile !== false) {
3788
+ await truncateRedirectLogs(logsDirPath(env)).catch(() => undefined);
3789
+ }
3790
+ // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
3791
+ // command neither needs nor should pay for (Node still prints an
3792
+ // experimental warning for it).
3793
+ const { createGateway } = await import('@stratusagent/gateway');
3794
+ // Under a service manager the daemon's stdout is gone, so everything it
3795
+ // says is also written to ~/.stratus/logs — that file is what `stratus
3796
+ // logs` reads, and the only record of an overnight run.
3797
+ const logWriter = command.logToFile === false
3798
+ ? undefined
3799
+ : createLogWriter({
3800
+ dir: logsDirPath(env),
3801
+ onError: (error) => writeLine(streams.stderr, `Warning: could not write the log file (${error instanceof Error ? error.message : String(error)}); continuing.`),
3802
+ });
3803
+ const log = (line) => {
3804
+ writeLine(streams.stdout, line);
3805
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'info', msg: line });
3806
+ };
3807
+ const warn = (line) => {
3808
+ writeLine(streams.stderr, `Warning: ${line}`);
3809
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'warn', msg: line });
3810
+ };
3811
+ // Agents with stored Slack tokens go live in Slack automatically — the
3812
+ // tokens are gateway infrastructure secrets in the channels namespace of
3813
+ // ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
3814
+ // for the 2-minute per-agent app setup).
3815
+ const channelCredentials = await loadChannelCredentials(env);
3816
+ const slackAgents = Object.entries(channelCredentials.slack ?? {});
3817
+ const channels = [];
3818
+ if (slackAgents.length > 0) {
3819
+ // Channel packages are optional peers: the CLI never bundles a
3820
+ // transport nobody asked for (the Slack SDKs alone are ~9 MB). A
3821
+ // missing one degrades like a broken app does — the daemon serves
3822
+ // every other channel — with an actionable line instead of a
3823
+ // module-not-found stack.
3824
+ const adapter = await loadSlackAdapter();
3825
+ if (adapter) {
3826
+ channels.push(adapter({
3827
+ agents: slackAgents.map(([agentId, tokens]) => ({
3828
+ agentId,
3829
+ appToken: tokens.appToken,
3830
+ botToken: tokens.botToken,
3831
+ })),
3832
+ log,
3833
+ warn,
3834
+ }));
3835
+ }
3836
+ else {
3837
+ warn(`Slack tokens are stored for ${slackAgents.length} agent(s), but @stratusagent/channel-slack is not installed. `
3838
+ + 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
3839
+ }
3840
+ }
3841
+ // A daemon is the costliest place for a silently demoted subscription:
3842
+ // every dispatch for as long as it runs, with nobody watching. The
3843
+ // gateway resolves each agent's soul independently on dispatch, so an
3844
+ // agent pinning anthropic can pick up an environment key even when the
3845
+ // daemon's own default is openai — checking only the default selection
3846
+ // would miss exactly the agent that is being billed. Each distinct
3847
+ // selection is resolved once here, and duplicate warnings are collapsed
3848
+ // so a ten-agent roster does not print ten identical lines.
3849
+ {
3850
+ const warned = new Set();
3851
+ const collect = (line) => {
3852
+ if (warned.has(line)) {
3853
+ return;
3854
+ }
3855
+ warned.add(line);
3856
+ // Through `warn`, not straight to stderr: under a service manager
3857
+ // stderr is gone, and a cost warning that only exists there is
3858
+ // invisible in exactly the deployment it matters for. `warn` adds
3859
+ // its own prefix, so the one already on the line comes off.
3860
+ warn(line.replace(/^Warning: /, ''));
3861
+ };
3862
+ const captured = {
3863
+ stdout: { write: () => true },
3864
+ stderr: { write: (chunk) => { collect(chunk.replace(/\n$/, '')); return true; } },
3865
+ };
3866
+ // A pinned soul does not merely add a provider — the gateway DEMOTES
3867
+ // the daemon-wide defaults it outranks, including STRATUS_PROVIDER, so
3868
+ // each served runtime is resolved the way a dispatch resolves it.
3869
+ for (const served of await servedRuntimes(env, command.configPath)) {
3870
+ await warnOnCredentialOverride(served.runtime, captured, served.env);
3871
+ }
3872
+ }
3873
+ // The daemon had no policy at all: createGateway falls back to
3874
+ // AllowAllApprovalPolicy, so every tool call from every agent
3875
+ // auto-approved, indefinitely, with nobody watching. That was survivable
3876
+ // while `serve` was something you ran in a terminal you were sitting at;
3877
+ // it is not, now that setup installs it under launchd by default.
3878
+ //
3879
+ // Headless is the only honest mode here until a turn can park and ask
3880
+ // through a channel: there is no terminal behind a service manager, so
3881
+ // `safe` runs and anything riskier is refused rather than waved through.
3882
+ // Every refusal is logged — an unattended denial that appears nowhere
3883
+ // reads like an agent that decided not to bother.
3884
+ const approvals = createPermissionPolicy({
3885
+ mode: 'headless',
3886
+ onDecision: (decision) => {
3887
+ if (decision.allowed) {
3888
+ return;
3889
+ }
3890
+ warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
3891
+ },
3892
+ });
3893
+ const gateway = createGateway({
3894
+ env,
3895
+ approvals,
3896
+ ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
3897
+ ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
3898
+ ...(channels.length > 0 ? { channels } : {}),
3899
+ log,
3900
+ warn,
3901
+ });
3902
+ if (command.events) {
3903
+ gateway.bus.subscribe((event) => {
3904
+ const line = formatEvent(event);
3905
+ if (line) {
3906
+ writeLine(streams.stdout, line);
3907
+ }
3908
+ });
3909
+ }
3910
+ if (logWriter) {
3911
+ // Only session.created carries the agent id, so it seeds a map the
3912
+ // later events in that session read from. A session resumed after a
3913
+ // restart never re-creates, so an unmapped id falls back to the
3914
+ // durable store — which is exactly the case where attribution matters.
3915
+ const agentBySession = new Map();
3916
+ // EventBus.emit awaits its subscribers, and a streaming provider awaits
3917
+ // the delta sink — so anything awaited here lands on the critical path
3918
+ // of every streamed token. This handler is deliberately synchronous:
3919
+ // provider.delta is dropped outright (it carries no detail worth
3920
+ // keeping, one record per token), the timestamp is taken now, and the
3921
+ // write is queued without awaiting it.
3922
+ gateway.bus.subscribe((event) => {
3923
+ if (event.type === 'provider.delta') {
3924
+ return;
3925
+ }
3926
+ const ts = new Date().toISOString();
3927
+ if (event.type === 'session.created') {
3928
+ agentBySession.set(event.sessionId, event.agentId);
3929
+ }
3930
+ const detail = eventDetail(event);
3931
+ const base = {
3932
+ ts,
3933
+ level: 'event',
3934
+ event: event.type,
3935
+ sessionId: event.sessionId,
3936
+ ...(detail ? { detail } : {}),
3937
+ };
3938
+ const known = agentBySession.get(event.sessionId);
3939
+ if (known) {
3940
+ void logWriter.write({ ...base, agentId: known });
3941
+ }
3942
+ else {
3943
+ // A session resumed after a restart never re-creates, so its agent
3944
+ // is only in the store. That lookup is deferred off this path; the
3945
+ // timestamp above keeps ordering honest.
3946
+ void gateway.store.get(event.sessionId)
3947
+ .then((session) => {
3948
+ const agentId = session?.agent.id;
3949
+ if (agentId) {
3950
+ agentBySession.set(event.sessionId, agentId);
3951
+ }
3952
+ return logWriter.write({ ...base, ...(agentId ? { agentId } : {}) });
3953
+ })
3954
+ .catch(() => logWriter.write(base));
3955
+ }
3956
+ if (event.type === 'session.completed' || event.type === 'session.failed') {
3957
+ agentBySession.delete(event.sessionId);
3958
+ }
3959
+ });
3960
+ }
3961
+ await gateway.start();
3962
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
3963
+ // And periodically, for a long-running daemon that warns steadily
3964
+ // without ever writing enough records to rotate. Unref'd, so it never
3965
+ // holds the process open. Armed only once the daemon is actually
3966
+ // serving: every step above can throw, and runServe is an exported
3967
+ // function as much as a process entry point — in a host that survives
3968
+ // the failure, a timer armed before the throw would outlive the call
3969
+ // and go on truncating that environment's redirect logs. The finally
3970
+ // below takes it down on every other exit path.
3971
+ let redirectTimer;
3972
+ if (logWriter) {
3973
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
3974
+ redirectTimer.unref?.();
3975
+ }
3976
+ try {
3977
+ // Hold the event loop open until a shutdown request, then drain: new
3978
+ // dispatches are refused while in-flight turns finish.
3979
+ await new Promise((resolve) => {
3980
+ const keepAlive = setInterval(() => { }, 2_147_000_000);
3981
+ let settled = false;
3982
+ const shutdown = () => {
3983
+ if (settled) {
3984
+ return;
3985
+ }
3986
+ settled = true;
3987
+ clearInterval(keepAlive);
3988
+ process.off('SIGTERM', shutdown);
3989
+ process.off('SIGINT', shutdown);
3990
+ resolve();
3991
+ };
3992
+ process.once('SIGTERM', shutdown);
3993
+ process.once('SIGINT', shutdown);
3994
+ if (env.shutdownSignal?.aborted) {
3995
+ shutdown();
3996
+ }
3997
+ else {
3998
+ env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
3999
+ }
4000
+ });
4001
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
4002
+ await gateway.stop();
4003
+ return 0;
4004
+ }
4005
+ finally {
4006
+ if (redirectTimer) {
4007
+ clearInterval(redirectTimer);
4008
+ }
4009
+ // Writes are queued and dropped on the hot path so logging never sits
4010
+ // on a streamed token. That makes the tail of the log the part most
4011
+ // likely to be lost — and the tail is the shutdown reason, the last
4012
+ // warning, the line explaining a restart.
4013
+ await logWriter?.flush();
4014
+ }
4015
+ };
2670
4016
  export const runCli = async ({ argv, streams = process, env = {} }) => {
2671
4017
  try {
2672
4018
  const resolvedEnv = argv.includes('--stdin') && env.stdin === undefined
@@ -2680,16 +4026,40 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2680
4026
  writeLine(streams.stdout, HELP_TEXT);
2681
4027
  return 0;
2682
4028
  }
4029
+ // Every handler is awaited, never bare-returned: a bare `return
4030
+ // promise` inside try/catch settles the async function before the
4031
+ // catch can see it, so a command that fails at runtime — a gateway
4032
+ // that cannot open its store, a missing credential — would escape as
4033
+ // a raw rejection instead of the error line and exit code below.
2683
4034
  if (command.command === 'agent-new') {
2684
- return runAgentNew(command, streams, resolvedEnv);
4035
+ return await runAgentNew(command, streams, resolvedEnv);
4036
+ }
4037
+ if (command.command === 'agents') {
4038
+ return await runAgents(command, streams, resolvedEnv);
4039
+ }
4040
+ if (command.command === 'doctor') {
4041
+ return await runDoctor(command, streams, resolvedEnv);
4042
+ }
4043
+ if (command.command === 'logs') {
4044
+ return await runLogs(command, streams, resolvedEnv);
4045
+ }
4046
+ if (command.command === 'service') {
4047
+ return await runService(command, streams, resolvedEnv);
4048
+ }
4049
+ if (command.command === 'chat') {
4050
+ return await runChat(command, streams, resolvedEnv);
2685
4051
  }
2686
4052
  if (command.command === 'setup') {
2687
- return runSetup(command, streams, resolvedEnv);
4053
+ return await runSetup(command, streams, resolvedEnv);
2688
4054
  }
2689
4055
  if (command.command === 'dashboard') {
2690
- return runDashboard(command, streams, resolvedEnv);
4056
+ return await runDashboard(command, streams, resolvedEnv);
4057
+ }
4058
+ if (command.command === 'serve') {
4059
+ return await runServe(command, streams, resolvedEnv);
2691
4060
  }
2692
4061
  const runtime = await resolveRuntimeConfig(command, resolvedEnv);
4062
+ await warnOnCredentialOverride(runtime, streams, resolvedEnv);
2693
4063
  if (command.format === 'text') {
2694
4064
  writeLine(streams.stdout, formatRuntimeBanner(runtime));
2695
4065
  }