@stratusagent/cli 0.2.2 → 0.2.5

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,28 @@
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 { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
14
11
  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';
12
+ import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveRuntimeConfig as resolveStateRuntimeConfig, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
13
+ // The shared state package owns config resolution, credentials, memory, and
14
+ // provider wiring now (the gateway uses the same code); the CLI re-exports
15
+ // its historical surface so existing importers keep working.
16
+ export { createFileMemoryStore } from '@stratusagent/state';
17
+ export const CLI_VERSION = '0.2.5';
46
18
  const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
47
19
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
48
20
  const HELP_TEXT = `Stratus Agent CLI
49
21
 
50
22
  Usage:
51
23
  stratus setup
24
+ stratus chat
25
+ stratus chat --soul ./examples/souls/ava.md
52
26
  stratus run --prompt "Use the demo tool"
53
27
  stratus run "Say hello"
54
28
  ANTHROPIC_API_KEY=... stratus run --provider anthropic "Say hello"
@@ -56,6 +30,7 @@ Usage:
56
30
  echo "Use the echo tool" | stratus run --stdin
57
31
  STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
58
32
  stratus run --config ./stratus.config.json --provider openai "Say hello"
33
+ stratus agents
59
34
  stratus dashboard
60
35
  stratus dashboard --port 4123 --host 0.0.0.0 --no-open
61
36
 
@@ -64,8 +39,16 @@ Commands:
64
39
  subscription or API key), create your agent, and test it —
65
40
  settings go to ~/.stratus/config.json, sign-ins to
66
41
  ~/.stratus/credentials.json (0600)
42
+ chat Talk with your agent — the conversation persists across turns
43
+ and remembered facts accumulate; /exit or Ctrl+C to leave
67
44
  run Execute one local Stratus Agent session
45
+ serve Run stratusd, the always-on gateway: durable sessions, the
46
+ whole roster live at once (each agent on its own provider),
47
+ delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
48
+ (--idle-timeout <seconds>, --no-events, --config <path>)
68
49
  agent new Create an agent identity (generates a human-ish name + avatar theme)
50
+ agents List your agents: who they are, where their souls live, what
51
+ they run on, what they remember (also: stratus agent list)
69
52
  dashboard Start the local Stratus Agent dashboard and open it in your browser
70
53
  help Show this help message
71
54
 
@@ -110,207 +93,6 @@ const stringifyValue = (value) => {
110
93
  }
111
94
  return JSON.stringify(value, null, 2);
112
95
  };
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
96
  const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
315
97
  const readPromptFromStdin = async (stdin) => {
316
98
  stdin.setEncoding('utf8');
@@ -320,54 +102,6 @@ const readPromptFromStdin = async (stdin) => {
320
102
  }
321
103
  return data.trim();
322
104
  };
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
105
  const readOptionValue = (tokens, index, flag) => {
372
106
  const value = tokens[index + 1];
373
107
  if (!value) {
@@ -414,13 +148,130 @@ export const parseCommand = (argv, env = {}) => {
414
148
  }
415
149
  return { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
416
150
  }
151
+ if (command === 'serve') {
152
+ const parsed = { command: 'serve', events: true };
153
+ for (let index = 0; index < rest.length; index += 1) {
154
+ const token = rest[index];
155
+ if (!token) {
156
+ continue;
157
+ }
158
+ if (token === '--help' || token === '-h') {
159
+ return { command: 'help' };
160
+ }
161
+ if (token === '--config') {
162
+ parsed.configPath = readOptionValue(rest, index, '--config');
163
+ index += 1;
164
+ continue;
165
+ }
166
+ if (token === '--idle-timeout') {
167
+ const seconds = Number(readOptionValue(rest, index, '--idle-timeout'));
168
+ if (!Number.isFinite(seconds) || seconds < 0) {
169
+ throw new Error(`Invalid value for --idle-timeout: ${rest[index + 1] ?? '(missing)'}`);
170
+ }
171
+ parsed.idleTimeoutMs = Math.round(seconds * 1000);
172
+ index += 1;
173
+ continue;
174
+ }
175
+ if (token === '--no-events') {
176
+ parsed.events = false;
177
+ continue;
178
+ }
179
+ throw new Error(`Unknown option: ${token}`);
180
+ }
181
+ return parsed;
182
+ }
183
+ if (command === 'chat') {
184
+ const parsed = { command: 'chat', events: false, approvals: 'always' };
185
+ for (let index = 0; index < rest.length; index += 1) {
186
+ const token = rest[index];
187
+ if (!token) {
188
+ continue;
189
+ }
190
+ if (token === '--help' || token === '-h') {
191
+ return { command: 'help' };
192
+ }
193
+ if (token === '--provider') {
194
+ parsed.provider = parseProviderName(readOptionValue(rest, index, '--provider'), '--provider');
195
+ index += 1;
196
+ continue;
197
+ }
198
+ if (token === '--model') {
199
+ parsed.model = readOptionValue(rest, index, '--model');
200
+ index += 1;
201
+ continue;
202
+ }
203
+ if (token === '--base-url') {
204
+ parsed.baseUrl = readOptionValue(rest, index, '--base-url');
205
+ index += 1;
206
+ continue;
207
+ }
208
+ if (token === '--soul') {
209
+ parsed.soul = readOptionValue(rest, index, '--soul');
210
+ index += 1;
211
+ continue;
212
+ }
213
+ if (token === '--config') {
214
+ parsed.configPath = readOptionValue(rest, index, '--config');
215
+ index += 1;
216
+ continue;
217
+ }
218
+ if (token === '--approvals') {
219
+ const value = readOptionValue(rest, index, '--approvals');
220
+ if (value !== 'always' && value !== 'ask' && value !== 'never') {
221
+ throw new Error(`Invalid value for --approvals: ${value}`);
222
+ }
223
+ parsed.approvals = value;
224
+ index += 1;
225
+ continue;
226
+ }
227
+ if (token === '--max-turns') {
228
+ const value = Number(readOptionValue(rest, index, '--max-turns'));
229
+ if (!Number.isInteger(value) || value < 1) {
230
+ throw new Error(`Invalid value for --max-turns: ${rest[index + 1] ?? '(missing)'}`);
231
+ }
232
+ parsed.maxTurns = value;
233
+ index += 1;
234
+ continue;
235
+ }
236
+ if (token === '--events') {
237
+ parsed.events = true;
238
+ continue;
239
+ }
240
+ throw new Error(`Unknown option: ${token}`);
241
+ }
242
+ return parsed;
243
+ }
244
+ if (command === 'agents' || (command === 'agent' && rest[0] === 'list')) {
245
+ const agentsRest = command === 'agents' ? rest : rest.slice(1);
246
+ let format = 'text';
247
+ for (let index = 0; index < agentsRest.length; index += 1) {
248
+ const token = agentsRest[index];
249
+ if (!token) {
250
+ continue;
251
+ }
252
+ if (token === '--help' || token === '-h') {
253
+ return { command: 'help' };
254
+ }
255
+ if (token === '--format') {
256
+ const value = readOptionValue(agentsRest, index, '--format');
257
+ if (value !== 'text' && value !== 'json') {
258
+ throw new Error(`Unsupported format: ${value}`);
259
+ }
260
+ format = value;
261
+ index += 1;
262
+ continue;
263
+ }
264
+ throw new Error(`Unknown option: ${token}`);
265
+ }
266
+ return { command: 'agents', format };
267
+ }
417
268
  if (command === 'agent') {
418
269
  const [subcommand, ...agentRest] = rest;
419
270
  if (subcommand === '--help' || subcommand === '-h') {
420
271
  return { command: 'help' };
421
272
  }
422
273
  if (subcommand !== 'new') {
423
- throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new`);
274
+ throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new, stratus agent list`);
424
275
  }
425
276
  let name;
426
277
  let instructions;
@@ -621,359 +472,10 @@ export const formatEvent = (event) => {
621
472
  return null;
622
473
  }
623
474
  };
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;
696
- }
697
- return map ? map(trimmed) : trimmed;
698
- };
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 });
726
- }
727
- catch (error) {
728
- throw new Error(`Could not parse soul file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`);
729
- }
730
- };
731
- export const resolveRuntimeConfig = async (command, env = {}) => {
732
- 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
797
- : 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
- });
966
- }
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
- });
975
- };
976
- const createApprovalPolicy = (mode, streams, env) => {
475
+ // Kept with its historical CLI signature: a parsed run command is a
476
+ // RuntimeSelection plus CLI-only fields the resolver ignores.
477
+ export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
478
+ const createApprovalPolicy = (mode, streams, env, ask) => {
977
479
  if (mode === 'always') {
978
480
  return new AllowAllApprovalPolicy();
979
481
  }
@@ -984,6 +486,16 @@ const createApprovalPolicy = (mode, streams, env) => {
984
486
  },
985
487
  };
986
488
  }
489
+ // A caller that already owns stdin (chat's readline) supplies its own
490
+ // asker — two readers on one stream would race for the same bytes.
491
+ if (ask) {
492
+ return {
493
+ async approve({ call }) {
494
+ const answer = await ask(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
495
+ return /^y(es)?$/i.test(answer.trim());
496
+ },
497
+ };
498
+ }
987
499
  return {
988
500
  async approve({ call }) {
989
501
  const input = env.approvalInput ?? process.stdin;
@@ -1004,7 +516,11 @@ const createApprovalPolicy = (mode, streams, env) => {
1004
516
  },
1005
517
  };
1006
518
  };
1007
- export const runSingleLoop = async (prompt, streams, options) => {
519
+ // The shared wiring behind every conversational command: memory (with
520
+ // legacy migration), tools, events, provider (with fallback), and the
521
+ // agent the runtime resolved. run uses it for one shot; chat keeps the
522
+ // runner alive and resumes the same session turn after turn.
523
+ const createAgentRuntime = async (streams, options) => {
1008
524
  const runEnv = options.env ?? {};
1009
525
  await migrateLegacyMemory(runEnv);
1010
526
  const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(runEnv)));
@@ -1016,7 +532,13 @@ export const runSingleLoop = async (prompt, streams, options) => {
1016
532
  writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
1017
533
  },
1018
534
  });
1019
- if (options.events ?? true) {
535
+ if (options.onEvent) {
536
+ const onEvent = options.onEvent;
537
+ bus.subscribe(async (event) => {
538
+ onEvent(event);
539
+ });
540
+ }
541
+ else if (options.events ?? true) {
1020
542
  bus.subscribe(async (event) => {
1021
543
  const line = formatEvent(event);
1022
544
  if (line) {
@@ -1024,40 +546,49 @@ export const runSingleLoop = async (prompt, streams, options) => {
1024
546
  }
1025
547
  });
1026
548
  }
549
+ // The Claude Code runtime executes kernel tools by calling back into the
550
+ // runner built just below — late-bound because the runner needs the
551
+ // provider first.
552
+ let hostedRunner;
1027
553
  const runtimeProvider = createRuntimeProvider(options.runtime, (error) => {
1028
554
  const fallback = options.runtime.provider === 'demo' ? undefined : options.runtime.fallback;
1029
555
  writeLine(streams.stderr, `Warning: the default model failed (${error instanceof Error ? error.message : String(error)}); falling back to ${fallback?.model ?? 'the fallback model'}.`);
1030
- });
556
+ }, async (session, call, context) => {
557
+ if (!hostedRunner) {
558
+ throw new Error('The Stratus runtime is not ready to execute tools yet.');
559
+ }
560
+ return hostedRunner.executeHostedToolCall(session, call, context);
561
+ }, options.maxTurns);
1031
562
  const runner = new AgentRunner({
1032
563
  provider: runtimeProvider,
1033
564
  tools,
1034
565
  executor: createLocalCommandExecutor(),
1035
- approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
566
+ approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
1036
567
  bus,
1037
568
  memory,
1038
569
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
1039
570
  });
571
+ hostedRunner = runner;
1040
572
  await runner.initialize();
1041
573
  // A soul is a full identity — without one, every provider serves the
1042
574
  // same built-in Stratus persona.
1043
575
  const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
1044
576
  const metadata = options.runtime.provider === 'demo'
1045
- ? { provider: 'demo' }
577
+ ? { provider: 'demo', executor: 'local-command' }
1046
578
  : {
1047
579
  provider: options.runtime.provider,
1048
580
  model: options.runtime.model,
1049
581
  ...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
1050
582
  };
583
+ return { runner, agent, metadata };
584
+ };
585
+ export const runSingleLoop = async (prompt, streams, options) => {
586
+ const { runner, agent, metadata } = await createAgentRuntime(streams, options);
1051
587
  return runner.run({
1052
588
  sessionId: randomUUID(),
1053
589
  agent,
1054
590
  userMessage: prompt,
1055
- metadata: options.runtime.provider === 'demo'
1056
- ? {
1057
- ...metadata,
1058
- executor: 'local-command',
1059
- }
1060
- : metadata,
591
+ metadata,
1061
592
  });
1062
593
  };
1063
594
  export const printSessionSummary = (session, streams) => {
@@ -1077,6 +608,219 @@ export const printSessionSummary = (session, streams) => {
1077
608
  writeLine(streams.stdout, `[${message.role}${nameSuffix}] ${content}`);
1078
609
  }
1079
610
  };
611
+ const lastAssistantReply = (session) => {
612
+ for (let index = session.messages.length - 1; index >= 0; index -= 1) {
613
+ const message = session.messages[index];
614
+ if (message && message.role === 'assistant' && message.content.trim().length > 0) {
615
+ return message.content;
616
+ }
617
+ }
618
+ return '(no reply)';
619
+ };
620
+ const CHAT_HELP = [
621
+ ' /help show this',
622
+ ' /exit leave the chat (Ctrl+C and Ctrl+D work too)',
623
+ 'Everything else is a message to your agent. The conversation persists',
624
+ 'across turns, and facts they remember stick forever.',
625
+ ].join('\n');
626
+ export const runChat = async (command, streams, env = {}) => {
627
+ const runtime = await resolveRuntimeConfig({
628
+ command: 'run',
629
+ prompt: '',
630
+ format: 'text',
631
+ events: false,
632
+ approvals: command.approvals,
633
+ ...(command.provider ? { provider: command.provider } : {}),
634
+ ...(command.model ? { model: command.model } : {}),
635
+ ...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
636
+ ...(command.soul ? { soul: command.soul } : {}),
637
+ ...(command.configPath ? { configPath: command.configPath } : {}),
638
+ }, env);
639
+ // Interactive means the real terminal: an injected stdinStream is by
640
+ // definition not a TTY conversation, so it never gets prompts, ANSI
641
+ // styling, or a terminal readline wired to a stream that can't take it.
642
+ const interactive = env.setupInput === undefined
643
+ && env.stdinStream === undefined
644
+ && process.stdin.isTTY === true;
645
+ // Styling is for eyes: piped transcripts stay plain text.
646
+ const bold = (text) => (interactive ? `\u001b[1m${text}\u001b[0m` : text);
647
+ const dim = (text) => (interactive ? `\u001b[2m${text}\u001b[0m` : text);
648
+ const input = env.stdinStream ?? process.stdin;
649
+ const rl = interactive
650
+ ? createInterface({
651
+ input,
652
+ output: streams.stdout,
653
+ prompt: '\u001b[36myou ›\u001b[0m ',
654
+ terminal: true,
655
+ })
656
+ : createInterface({ input, terminal: false });
657
+ // One reader owns stdin. Every line lands in this queue, and whoever is
658
+ // waiting — the chat loop, or an approval question mid-turn — takes the
659
+ // next one. A second readline would race for the same bytes, and
660
+ // rl.question cannot be used while the interface is being iterated.
661
+ const lineQueue = [];
662
+ let inputClosed = false;
663
+ let pendingApproval;
664
+ let notifyLine;
665
+ rl.on('line', (rawLine) => {
666
+ if (pendingApproval) {
667
+ const resolve = pendingApproval;
668
+ pendingApproval = undefined;
669
+ resolve(rawLine);
670
+ return;
671
+ }
672
+ lineQueue.push(rawLine);
673
+ notifyLine?.();
674
+ });
675
+ rl.on('close', () => {
676
+ inputClosed = true;
677
+ if (pendingApproval) {
678
+ const resolve = pendingApproval;
679
+ pendingApproval = undefined;
680
+ resolve('');
681
+ }
682
+ notifyLine?.();
683
+ });
684
+ const nextLine = () => {
685
+ if (lineQueue.length > 0) {
686
+ return Promise.resolve(lineQueue.shift());
687
+ }
688
+ if (inputClosed) {
689
+ return Promise.resolve(undefined);
690
+ }
691
+ return new Promise((resolve) => {
692
+ notifyLine = () => {
693
+ notifyLine = undefined;
694
+ resolve(lineQueue.shift());
695
+ };
696
+ });
697
+ };
698
+ // An approval consumes the next unconsumed line (typed live, or already
699
+ // queued from piped input). A closed stream denies instead of hanging.
700
+ const askApproval = (prompt) => {
701
+ streams.stderr.write(prompt);
702
+ const queued = lineQueue.shift();
703
+ if (queued !== undefined) {
704
+ return Promise.resolve(queued);
705
+ }
706
+ if (inputClosed) {
707
+ return Promise.resolve('');
708
+ }
709
+ return new Promise((resolve) => {
710
+ pendingApproval = resolve;
711
+ });
712
+ };
713
+ const { runner, agent, metadata } = await createAgentRuntime(streams, {
714
+ runtime,
715
+ approvals: command.approvals,
716
+ askApproval,
717
+ ...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
718
+ env,
719
+ onEvent: (event) => {
720
+ if (command.events) {
721
+ const line = formatEvent(event);
722
+ if (line) {
723
+ writeLine(streams.stdout, dim(line));
724
+ }
725
+ return;
726
+ }
727
+ // Quiet by default — just a whisper when the agent reaches for a tool.
728
+ if (event.type === 'tool.called') {
729
+ writeLine(streams.stdout, dim(` · using ${event.call.toolName}`));
730
+ }
731
+ else if (event.type === 'tool.denied') {
732
+ writeLine(streams.stdout, dim(` · ${event.call.toolName} denied`));
733
+ }
734
+ },
735
+ });
736
+ const modelLine = runtime.provider === 'demo'
737
+ ? 'demo (offline)'
738
+ : `${runtime.provider} · ${runtime.model}`;
739
+ if (interactive) {
740
+ streams.stdout.write('\u001b[2J\u001b[H');
741
+ for (const line of stratusHeaderLines()) {
742
+ writeLine(streams.stdout, line);
743
+ }
744
+ writeLine(streams.stdout);
745
+ writeLine(streams.stdout, `Chatting with ${bold(agent.name)} — ${modelLine}.`);
746
+ writeLine(streams.stdout, dim('The conversation persists across turns. /exit to leave, /help for more.'));
747
+ writeLine(streams.stdout);
748
+ }
749
+ // Ctrl+C between turns closes the chat gracefully; mid-turn there is
750
+ // nothing to cancel in the kernel yet, so leave immediately instead of
751
+ // silently waiting out a slow provider call.
752
+ let turnInFlight = false;
753
+ rl.on('SIGINT', () => {
754
+ if (turnInFlight) {
755
+ writeLine(streams.stdout);
756
+ writeLine(streams.stdout, dim('Interrupted.'));
757
+ process.exit(130);
758
+ }
759
+ rl.close();
760
+ });
761
+ // One session for the whole sitting: the first message starts it, every
762
+ // later message resumes it — the same plumbing a channel will use to
763
+ // keep a thread alive.
764
+ let sessionId;
765
+ if (interactive) {
766
+ rl.prompt();
767
+ }
768
+ for (;;) {
769
+ const rawLine = await nextLine();
770
+ if (rawLine === undefined) {
771
+ break;
772
+ }
773
+ const line = rawLine.trim();
774
+ if (line.length === 0) {
775
+ if (interactive) {
776
+ rl.prompt();
777
+ }
778
+ continue;
779
+ }
780
+ if (line === '/exit' || line === '/quit' || line === 'exit' || line === 'quit') {
781
+ break;
782
+ }
783
+ if (line === '/help') {
784
+ writeLine(streams.stdout, CHAT_HELP);
785
+ if (interactive) {
786
+ rl.prompt();
787
+ }
788
+ continue;
789
+ }
790
+ if (!interactive) {
791
+ writeLine(streams.stdout, `you › ${line}`);
792
+ }
793
+ try {
794
+ turnInFlight = true;
795
+ let session;
796
+ if (sessionId === undefined) {
797
+ const id = randomUUID();
798
+ sessionId = id;
799
+ session = await runner.run({ sessionId: id, agent, userMessage: line, metadata });
800
+ }
801
+ else {
802
+ session = await runner.resume({ sessionId, userMessage: line });
803
+ }
804
+ writeLine(streams.stdout, `${bold(`${agent.name} ›`)} ${lastAssistantReply(session)}`);
805
+ }
806
+ catch (error) {
807
+ writeLine(streams.stderr, `Error: ${error instanceof Error ? error.message : String(error)}`);
808
+ writeLine(streams.stdout, dim('(that turn failed — the conversation is still here, try again)'));
809
+ }
810
+ finally {
811
+ turnInFlight = false;
812
+ }
813
+ writeLine(streams.stdout);
814
+ if (interactive) {
815
+ rl.prompt();
816
+ }
817
+ }
818
+ rl.close();
819
+ if (interactive) {
820
+ writeLine(streams.stdout, dim(`Bye — ${agent.name} keeps what they remembered.`));
821
+ }
822
+ return 0;
823
+ };
1080
824
  const formatRuntimeBanner = (runtime) => {
1081
825
  const soulSuffix = runtime.soul ? ` as ${runtime.soul.agent.name}` : '';
1082
826
  if (runtime.provider === 'demo') {
@@ -2493,6 +2237,150 @@ export const runSetup = async (command, streams, env = {}) => {
2493
2237
  prompter.close();
2494
2238
  }
2495
2239
  };
2240
+ const personaSnippet = (instructions) => {
2241
+ const firstLine = instructions?.split('\n').map((line) => line.trim()).find((line) => line.length > 0);
2242
+ if (!firstLine) {
2243
+ return undefined;
2244
+ }
2245
+ return firstLine.length > 78 ? `${firstLine.slice(0, 77)}…` : firstLine;
2246
+ };
2247
+ export const runAgents = async (command, streams, env = {}) => {
2248
+ // Fold any legacy per-directory memory in first so the counts below
2249
+ // reflect everything each agent actually remembers.
2250
+ await migrateLegacyMemory(env);
2251
+ const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
2252
+ const processEnv = readProcessEnv(env);
2253
+ // Listing must never be blocked by a broken config — it only feeds the
2254
+ // default marker and the "runs on" lines.
2255
+ const { config: activeConfig } = await discoverActiveConfig(env, (message) => {
2256
+ writeLine(streams.stderr, `Warning: ${message}.`);
2257
+ });
2258
+ const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
2259
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'));
2260
+ const envModel = readNonEmptyString(processEnv.STRATUS_MODEL)
2261
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL);
2262
+ // What a run as this soul would actually use right now — the same
2263
+ // precedence as resolveRuntimeConfig (env vars, soul hints, config,
2264
+ // demo), so a model-only or provider-only pin still resolves honestly.
2265
+ const runsOnFor = (soulProvider, soulModel) => {
2266
+ const provider = envProvider ?? soulProvider ?? activeConfig.provider ?? 'demo';
2267
+ if (provider === 'demo') {
2268
+ return { provider };
2269
+ }
2270
+ const soulModelApplies = soulProvider === undefined || soulProvider === provider;
2271
+ const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
2272
+ const model = envModel
2273
+ ?? (soulModelApplies ? soulModel : undefined)
2274
+ ?? (configModelApplies ? activeConfig.model : undefined)
2275
+ ?? (provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
2276
+ return { provider, model };
2277
+ };
2278
+ const defaultSoulPath = readNonEmptyString(processEnv.STRATUS_SOUL)
2279
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
2280
+ ?? activeConfig.soul;
2281
+ const resolvedDefaultSoul = defaultSoulPath
2282
+ ? path.resolve(readWorkingDirectory(env), defaultSoulPath)
2283
+ : undefined;
2284
+ const listings = [];
2285
+ const addSoul = async (soulPath) => {
2286
+ let parsed;
2287
+ try {
2288
+ parsed = parseSoul(await readFile(soulPath, 'utf8'), { seed: soulPath });
2289
+ }
2290
+ catch (error) {
2291
+ writeLine(streams.stderr, `Warning: skipping ${soulPath}: ${error instanceof Error ? error.message : String(error)}`);
2292
+ return;
2293
+ }
2294
+ const { agent } = parsed;
2295
+ const persona = personaSnippet(agent.instructions);
2296
+ listings.push({
2297
+ id: agent.id,
2298
+ name: agent.name,
2299
+ isDefault: soulPath === resolvedDefaultSoul,
2300
+ builtIn: false,
2301
+ soulPath,
2302
+ ...(parsed.provider ? { provider: parsed.provider } : {}),
2303
+ ...(parsed.model ? { model: parsed.model } : {}),
2304
+ runsOn: runsOnFor(parsed.provider, parsed.model),
2305
+ memories: (await memory.list(agent.id)).length,
2306
+ ...(persona ? { persona } : {}),
2307
+ ...(agent.avatar ? { avatar: `${agent.avatar.style} theme, hue ${agent.avatar.hue}, palette ${agent.avatar.palette.join(' ')}` } : {}),
2308
+ });
2309
+ };
2310
+ let rosterFiles = [];
2311
+ try {
2312
+ rosterFiles = (await readdir(agentsDirPath(env)))
2313
+ .filter((file) => file.endsWith('.md'))
2314
+ .sort()
2315
+ .map((file) => path.join(agentsDirPath(env), file));
2316
+ }
2317
+ catch (error) {
2318
+ if (error.code !== 'ENOENT') {
2319
+ throw error;
2320
+ }
2321
+ }
2322
+ for (const soulPath of rosterFiles) {
2323
+ await addSoul(soulPath);
2324
+ }
2325
+ // A default soul can live outside ~/.stratus/agents (a project soul, a
2326
+ // hand-written file) — the roster would be lying without it.
2327
+ if (resolvedDefaultSoul && !rosterFiles.includes(resolvedDefaultSoul)) {
2328
+ await addSoul(resolvedDefaultSoul);
2329
+ }
2330
+ // The built-in Stratus persona serves every run that has no soul.
2331
+ const builtInPersona = personaSnippet(DEFAULT_STRATUS_AGENT.instructions);
2332
+ listings.push({
2333
+ id: DEFAULT_STRATUS_AGENT.id,
2334
+ name: DEFAULT_STRATUS_AGENT.name,
2335
+ isDefault: resolvedDefaultSoul === undefined,
2336
+ builtIn: true,
2337
+ runsOn: runsOnFor(),
2338
+ memories: (await memory.list(DEFAULT_STRATUS_AGENT.id)).length,
2339
+ ...(builtInPersona ? { persona: builtInPersona } : {}),
2340
+ });
2341
+ listings.sort((a, b) => {
2342
+ if (a.isDefault !== b.isDefault) {
2343
+ return a.isDefault ? -1 : 1;
2344
+ }
2345
+ if (a.builtIn !== b.builtIn) {
2346
+ return a.builtIn ? 1 : -1;
2347
+ }
2348
+ return a.name.localeCompare(b.name);
2349
+ });
2350
+ if (command.format === 'json') {
2351
+ writeLine(streams.stdout, JSON.stringify({
2352
+ agents: listings.map(({ isDefault, ...rest }) => ({ ...rest, default: isDefault })),
2353
+ }, null, 2));
2354
+ return 0;
2355
+ }
2356
+ const describeRunsOn = (runsOn) => (runsOn.provider === 'demo' ? 'demo (offline)' : `${runsOn.provider}${runsOn.model ? ` · ${runsOn.model}` : ''}`);
2357
+ writeLine(streams.stdout, 'Agents');
2358
+ for (const agent of listings) {
2359
+ const labels = [
2360
+ ...(agent.isDefault ? ['default'] : []),
2361
+ ...(agent.builtIn ? ['built-in'] : []),
2362
+ ];
2363
+ writeLine(streams.stdout);
2364
+ writeLine(streams.stdout, ` ${agent.name}${labels.length > 0 ? ` (${labels.join(', ')})` : ''}`);
2365
+ writeLine(streams.stdout, ` id ${agent.id}`);
2366
+ if (agent.soulPath) {
2367
+ writeLine(streams.stdout, ` soul ${agent.soulPath}`);
2368
+ }
2369
+ writeLine(streams.stdout, ` runs on ${agent.provider ? describeRunsOn(agent.runsOn) : `follows your setup — currently ${describeRunsOn(agent.runsOn)}`}`);
2370
+ writeLine(streams.stdout, ` memory ${agent.memories === 0 ? 'nothing yet' : `${agent.memories} remembered fact${agent.memories === 1 ? '' : 's'}`}`);
2371
+ if (agent.persona) {
2372
+ writeLine(streams.stdout, ` persona ${agent.persona}`);
2373
+ }
2374
+ if (agent.avatar) {
2375
+ writeLine(streams.stdout, ` avatar ${agent.avatar}`);
2376
+ }
2377
+ }
2378
+ writeLine(streams.stdout);
2379
+ writeLine(streams.stdout, listings.length === 1
2380
+ ? 'That is just the built-in default — create your own with: stratus agent new'
2381
+ : 'Talk to the default with stratus run, or to anyone with stratus run --soul <file>.');
2382
+ return 0;
2383
+ };
2496
2384
  export const runAgentNew = async (command, streams, env = {}) => {
2497
2385
  // On a real terminal, creating an agent is the same guided experience as
2498
2386
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -2524,18 +2412,11 @@ export const runAgentNew = async (command, streams, env = {}) => {
2524
2412
  // at all: the soul keeps following the machine's configuration
2525
2413
  // instead of demanding credentials nothing has signed in for.
2526
2414
  const processEnv = readProcessEnv(env);
2527
- const configLocation = await resolveConfigLocation({}, env);
2528
2415
  // Creating an agent must not be blocked by a broken config — it only
2529
2416
  // 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
- }
2417
+ const { location: configLocation, config: activeConfig } = await discoverActiveConfig(env, (message) => {
2418
+ writeLine(streams.stdout, `Note: ${message}.`);
2419
+ });
2539
2420
  const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
2540
2421
  ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
2541
2422
  ?? activeConfig.provider
@@ -2667,6 +2548,119 @@ export const runDashboard = async (command, streams, env = {}) => {
2667
2548
  await handle.close();
2668
2549
  return 0;
2669
2550
  };
2551
+ /**
2552
+ * Loads the optional Slack channel package, or undefined when it is not
2553
+ * installed. Only that package being absent is tolerated: an adapter that
2554
+ * fails to load for any other reason (a broken install, a bad transitive
2555
+ * dependency) surfaces rather than silently disabling Slack for a daemon
2556
+ * whose stored tokens say it should be running.
2557
+ */
2558
+ const loadSlackAdapter = async () => {
2559
+ // Resolution and loading are separate questions, and only the first one
2560
+ // means "not installed". Inspecting the import's error message cannot
2561
+ // tell them apart: a package that IS installed but is missing one of
2562
+ // its own dependencies throws ERR_MODULE_NOT_FOUND naming that
2563
+ // dependency and this package as the importer — a broken install would
2564
+ // read as an absent one, silently disabling a channel whose tokens say
2565
+ // it should be running.
2566
+ try {
2567
+ import.meta.resolve('@stratusagent/channel-slack');
2568
+ }
2569
+ catch {
2570
+ return undefined;
2571
+ }
2572
+ // Resolvable: any failure from here is a real problem with the
2573
+ // installed package, and surfaces.
2574
+ return (await import('@stratusagent/channel-slack')).createSlackChannelAdapter;
2575
+ };
2576
+ /**
2577
+ * `stratus serve` — run the gateway (stratusd) in the foreground: load the
2578
+ * roster, accept dispatches, print events, and drain cleanly on SIGTERM,
2579
+ * SIGINT, or the injected shutdown signal.
2580
+ */
2581
+ export const runServe = async (command, streams, env = {}) => {
2582
+ // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
2583
+ // command neither needs nor should pay for (Node still prints an
2584
+ // experimental warning for it).
2585
+ const { createGateway } = await import('@stratusagent/gateway');
2586
+ const log = (line) => writeLine(streams.stdout, line);
2587
+ const warn = (line) => writeLine(streams.stderr, `Warning: ${line}`);
2588
+ // Agents with stored Slack tokens go live in Slack automatically — the
2589
+ // tokens are gateway infrastructure secrets in the channels namespace of
2590
+ // ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
2591
+ // for the 2-minute per-agent app setup).
2592
+ const channelCredentials = await loadChannelCredentials(env);
2593
+ const slackAgents = Object.entries(channelCredentials.slack ?? {});
2594
+ const channels = [];
2595
+ if (slackAgents.length > 0) {
2596
+ // Channel packages are optional peers: the CLI never bundles a
2597
+ // transport nobody asked for (the Slack SDKs alone are ~9 MB). A
2598
+ // missing one degrades like a broken app does — the daemon serves
2599
+ // every other channel — with an actionable line instead of a
2600
+ // module-not-found stack.
2601
+ const adapter = await loadSlackAdapter();
2602
+ if (adapter) {
2603
+ channels.push(adapter({
2604
+ agents: slackAgents.map(([agentId, tokens]) => ({
2605
+ agentId,
2606
+ appToken: tokens.appToken,
2607
+ botToken: tokens.botToken,
2608
+ })),
2609
+ log,
2610
+ warn,
2611
+ }));
2612
+ }
2613
+ else {
2614
+ warn(`Slack tokens are stored for ${slackAgents.length} agent(s), but @stratusagent/channel-slack is not installed. `
2615
+ + 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
2616
+ }
2617
+ }
2618
+ const gateway = createGateway({
2619
+ env,
2620
+ ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
2621
+ ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
2622
+ ...(channels.length > 0 ? { channels } : {}),
2623
+ log,
2624
+ warn,
2625
+ });
2626
+ if (command.events) {
2627
+ gateway.bus.subscribe((event) => {
2628
+ const line = formatEvent(event);
2629
+ if (line) {
2630
+ writeLine(streams.stdout, line);
2631
+ }
2632
+ });
2633
+ }
2634
+ await gateway.start();
2635
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
2636
+ // Hold the event loop open until a shutdown request, then drain: new
2637
+ // dispatches are refused while in-flight turns finish.
2638
+ await new Promise((resolve) => {
2639
+ const keepAlive = setInterval(() => { }, 2_147_000_000);
2640
+ let settled = false;
2641
+ const shutdown = () => {
2642
+ if (settled) {
2643
+ return;
2644
+ }
2645
+ settled = true;
2646
+ clearInterval(keepAlive);
2647
+ process.off('SIGTERM', shutdown);
2648
+ process.off('SIGINT', shutdown);
2649
+ resolve();
2650
+ };
2651
+ process.once('SIGTERM', shutdown);
2652
+ process.once('SIGINT', shutdown);
2653
+ if (env.shutdownSignal?.aborted) {
2654
+ shutdown();
2655
+ }
2656
+ else {
2657
+ env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
2658
+ }
2659
+ });
2660
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
2661
+ await gateway.stop();
2662
+ return 0;
2663
+ };
2670
2664
  export const runCli = async ({ argv, streams = process, env = {} }) => {
2671
2665
  try {
2672
2666
  const resolvedEnv = argv.includes('--stdin') && env.stdin === undefined
@@ -2683,12 +2677,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2683
2677
  if (command.command === 'agent-new') {
2684
2678
  return runAgentNew(command, streams, resolvedEnv);
2685
2679
  }
2680
+ if (command.command === 'agents') {
2681
+ return runAgents(command, streams, resolvedEnv);
2682
+ }
2683
+ if (command.command === 'chat') {
2684
+ return runChat(command, streams, resolvedEnv);
2685
+ }
2686
2686
  if (command.command === 'setup') {
2687
2687
  return runSetup(command, streams, resolvedEnv);
2688
2688
  }
2689
2689
  if (command.command === 'dashboard') {
2690
2690
  return runDashboard(command, streams, resolvedEnv);
2691
2691
  }
2692
+ if (command.command === 'serve') {
2693
+ return runServe(command, streams, resolvedEnv);
2694
+ }
2692
2695
  const runtime = await resolveRuntimeConfig(command, resolvedEnv);
2693
2696
  if (command.format === 'text') {
2694
2697
  writeLine(streams.stdout, formatRuntimeBanner(runtime));