@stratusagent/cli 0.2.1 → 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,31 +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 { createRememberTool, defineAgent, formatSoul, parseSoul, } from '@stratusagent/agents';
14
- const DEFAULT_CONFIG_FILENAME = 'stratus.config.json';
15
- const LEGACY_CONFIG_FILENAME = 'stratusclaw.config.json';
16
- const STRATUS_HOME_DIRNAME = '.stratus';
17
- const GLOBAL_CONFIG_FILENAME = 'config.json';
18
- const CREDENTIALS_FILENAME = 'credentials.json';
19
- const AGENTS_DIRNAME = 'agents';
20
- const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
21
- const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
22
- const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini';
9
+ import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
10
+ import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
11
+ import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
12
+ import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveRuntimeConfig as resolveStateRuntimeConfig, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
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';
23
18
  const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
24
19
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
25
20
  const HELP_TEXT = `Stratus Agent CLI
26
21
 
27
22
  Usage:
28
23
  stratus setup
24
+ stratus chat
25
+ stratus chat --soul ./examples/souls/ava.md
29
26
  stratus run --prompt "Use the demo tool"
30
27
  stratus run "Say hello"
31
28
  ANTHROPIC_API_KEY=... stratus run --provider anthropic "Say hello"
@@ -33,6 +30,7 @@ Usage:
33
30
  echo "Use the echo tool" | stratus run --stdin
34
31
  STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
35
32
  stratus run --config ./stratus.config.json --provider openai "Say hello"
33
+ stratus agents
36
34
  stratus dashboard
37
35
  stratus dashboard --port 4123 --host 0.0.0.0 --no-open
38
36
 
@@ -41,8 +39,16 @@ Commands:
41
39
  subscription or API key), create your agent, and test it —
42
40
  settings go to ~/.stratus/config.json, sign-ins to
43
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
44
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>)
45
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)
46
52
  dashboard Start the local Stratus Agent dashboard and open it in your browser
47
53
  help Show this help message
48
54
 
@@ -87,207 +93,6 @@ const stringifyValue = (value) => {
87
93
  }
88
94
  return JSON.stringify(value, null, 2);
89
95
  };
90
- const createDemoTool = () => defineLocalCommandTool({
91
- name: 'demo.echo',
92
- description: 'Return a tiny transformed summary for CLI demos through a real local process.',
93
- parameters: {
94
- type: 'object',
95
- properties: {
96
- text: { type: 'string', description: 'Text to echo back through the local process.' },
97
- },
98
- required: ['text'],
99
- },
100
- async createCommand(input) {
101
- const text = typeof input.text === 'string' ? input.text : '';
102
- const normalized = text.trim() || 'empty input';
103
- const script = `const text = ${JSON.stringify(normalized)}; console.log(JSON.stringify({ received: text, uppercase: text.toUpperCase(), length: text.length }));`;
104
- return {
105
- command: process.execPath,
106
- args: ['-e', script],
107
- };
108
- },
109
- parseResult(result) {
110
- return JSON.parse(result.stdout);
111
- },
112
- });
113
- const createDemoProvider = () => defineProvider({
114
- name: 'demo',
115
- async generate({ session }) {
116
- const builder = createProviderResponseBuilder();
117
- const lastMessage = session.messages.at(-1);
118
- if (lastMessage?.role === 'tool') {
119
- const result = lastMessage.toolResult;
120
- if (result?.ok) {
121
- builder.addText(`The demo.echo tool finished with: ${JSON.stringify(result.output)}`);
122
- }
123
- else {
124
- builder.addText(`The demo.echo tool did not run (${result?.error ?? 'unknown error'}), so this run ends here.`);
125
- }
126
- return builder.done();
127
- }
128
- const prompt = [...session.messages].reverse().find((message) => message.role === 'user')?.content?.trim() ?? '';
129
- const wantsTool = /\b(tool|echo|uppercase|inspect)\b/i.test(prompt);
130
- builder.addText(`Demo provider ready. Prompt received: ${prompt || '(empty)'}`);
131
- if (wantsTool) {
132
- builder.addToolCall({
133
- id: `${session.id}:call:demo-echo`,
134
- toolName: 'demo.echo',
135
- input: { text: prompt },
136
- });
137
- }
138
- else {
139
- builder.addText('No tool call was needed, so this run stays text-only. Mention “tool” or “echo” to trigger the demo tool.');
140
- }
141
- return builder.done();
142
- },
143
- });
144
- const MEMORY_FILENAME = 'memory.jsonl';
145
- const memoryFilePath = (env) => path.join(stratusHomePath(env), MEMORY_FILENAME);
146
- // Memory used to live under the working directory. Fold any such file into
147
- // the global store the first time a run happens from that directory, then
148
- // archive it — an upgrade must never look like the agent forgot.
149
- //
150
- // Every import first takes exclusive ownership by atomically renaming its
151
- // source to a unique claim file: of any competing processes, exactly one
152
- // wins the rename and the rest see ENOENT. A crash mid-import leaves the
153
- // claim file behind; later runs re-claim it the same way and finish the
154
- // job, with entries deduped against the global store by id. Only records
155
- // that parse as real memory entries are imported — malformed lines stay in
156
- // the archive instead of poisoning the global store for every agent.
157
- const isMemoryEntryLine = (line) => {
158
- try {
159
- const parsed = JSON.parse(line);
160
- return typeof parsed === 'object' && parsed !== null
161
- && typeof parsed.id === 'string'
162
- && typeof parsed.agentId === 'string'
163
- && typeof parsed.content === 'string';
164
- }
165
- catch {
166
- return false;
167
- }
168
- };
169
- const migrateLegacyMemory = async (env) => {
170
- const legacyPath = path.join(readWorkingDirectory(env), '.stratus', MEMORY_FILENAME);
171
- const globalPath = memoryFilePath(env);
172
- if (legacyPath === globalPath) {
173
- return;
174
- }
175
- const legacyDir = path.dirname(legacyPath);
176
- const archivePath = `${legacyPath}.migrated`;
177
- const claimAndImport = async (sourcePath) => {
178
- const claimPath = path.join(legacyDir, `${MEMORY_FILENAME}.migrating-${randomUUID()}`);
179
- try {
180
- await rename(sourcePath, claimPath);
181
- }
182
- catch (error) {
183
- if (error.code === 'ENOENT') {
184
- return; // another process owns it, or there is nothing to migrate
185
- }
186
- throw error;
187
- }
188
- const claimed = await readFile(claimPath, 'utf8');
189
- let existingIds;
190
- try {
191
- existingIds = new Set((await readFile(globalPath, 'utf8'))
192
- .split('\n')
193
- .filter(isMemoryEntryLine)
194
- .map((line) => JSON.parse(line).id));
195
- }
196
- catch {
197
- existingIds = new Set();
198
- }
199
- const entries = claimed
200
- .split('\n')
201
- .filter((line) => line.trim().length > 0)
202
- .filter(isMemoryEntryLine)
203
- .filter((line) => !existingIds.has(JSON.parse(line).id));
204
- if (entries.length > 0) {
205
- await mkdir(path.dirname(globalPath), { recursive: true });
206
- await appendFile(globalPath, `${entries.join('\n')}\n`);
207
- }
208
- // Archive by appending (never overwriting an earlier archive), then
209
- // drop the claim — its content is fully preserved in the archive.
210
- if (claimed.length > 0) {
211
- await appendFile(archivePath, claimed.endsWith('\n') || claimed.length === 0 ? claimed : `${claimed}\n`);
212
- }
213
- await unlink(claimPath);
214
- };
215
- await claimAndImport(legacyPath);
216
- // Finish any claims a crashed run left behind (both the current unique
217
- // names and the fixed .migrating name from earlier versions).
218
- let leftovers;
219
- try {
220
- leftovers = await readdir(legacyDir);
221
- }
222
- catch (error) {
223
- if (error.code === 'ENOENT') {
224
- return;
225
- }
226
- throw error;
227
- }
228
- for (const name of leftovers) {
229
- if (name.startsWith(`${MEMORY_FILENAME}.migrating`)) {
230
- await claimAndImport(path.join(legacyDir, name));
231
- }
232
- }
233
- };
234
- // Agents keep the same memory across CLI runs: every remembered fact lands
235
- // in ~/.stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
236
- // tomorrow — from any directory — is the Ava you talked to today. One JSON
237
- // entry per line, written with O_APPEND: concurrent runs each add their own
238
- // line instead of re-writing the file, so no run can clobber another's
239
- // remembered fact.
240
- export const createFileMemoryStore = (filePath) => {
241
- const readEntries = async () => {
242
- let raw;
243
- try {
244
- raw = await readFile(filePath, 'utf8');
245
- }
246
- catch (error) {
247
- if (error.code === 'ENOENT') {
248
- return [];
249
- }
250
- throw error;
251
- }
252
- return raw
253
- .split('\n')
254
- .filter((line) => line.trim().length > 0)
255
- .map((line) => {
256
- try {
257
- return JSON.parse(line);
258
- }
259
- catch {
260
- throw new Error(`Memory file has an invalid line: ${filePath}`);
261
- }
262
- });
263
- };
264
- return {
265
- async append(agentId, content, metadata) {
266
- const entry = {
267
- id: `${agentId}:memory:${randomUUID()}`,
268
- agentId,
269
- content,
270
- createdAt: new Date().toISOString(),
271
- ...(metadata ? { metadata } : {}),
272
- };
273
- await mkdir(path.dirname(filePath), { recursive: true });
274
- await appendFile(filePath, `${JSON.stringify(entry)}\n`);
275
- return entry;
276
- },
277
- async list(agentId) {
278
- // Dedupe by id at read time: even if a rare race double-imported a
279
- // fact, the agent only ever sees it once.
280
- const seen = new Set();
281
- return (await readEntries()).filter((entry) => {
282
- if (entry.agentId !== agentId || seen.has(entry.id)) {
283
- return false;
284
- }
285
- seen.add(entry.id);
286
- return true;
287
- });
288
- },
289
- };
290
- };
291
96
  const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
292
97
  const readPromptFromStdin = async (stdin) => {
293
98
  stdin.setEncoding('utf8');
@@ -297,54 +102,6 @@ const readPromptFromStdin = async (stdin) => {
297
102
  }
298
103
  return data.trim();
299
104
  };
300
- const parseProviderName = (value, label) => {
301
- if (value === 'demo' || value === 'openai' || value === 'anthropic') {
302
- return value;
303
- }
304
- throw new Error(`Unsupported provider in ${label}: ${value}`);
305
- };
306
- const readProcessEnv = (env) => env.processEnv ?? process.env;
307
- const readWorkingDirectory = (env) => env.cwd ?? process.cwd();
308
- const readHomeDirectory = (env) => env.homeDir ?? os.homedir();
309
- const stratusHomePath = (env) => path.join(readHomeDirectory(env), STRATUS_HOME_DIRNAME);
310
- const globalConfigPath = (env) => path.join(stratusHomePath(env), GLOBAL_CONFIG_FILENAME);
311
- const credentialsPath = (env) => path.join(stratusHomePath(env), CREDENTIALS_FILENAME);
312
- const agentsDirPath = (env) => path.join(stratusHomePath(env), AGENTS_DIRNAME);
313
- const loadCredentials = async (env) => {
314
- let raw;
315
- try {
316
- raw = await readFile(credentialsPath(env), 'utf8');
317
- }
318
- catch (error) {
319
- if (error.code === 'ENOENT') {
320
- return {};
321
- }
322
- throw error;
323
- }
324
- const parsed = JSON.parse(raw);
325
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
326
- throw new Error(`Credentials file must contain a JSON object: ${credentialsPath(env)}`);
327
- }
328
- const credentials = {};
329
- for (const provider of ['anthropic', 'openai']) {
330
- const entry = parsed[provider];
331
- if (typeof entry === 'object' && entry !== null && !Array.isArray(entry) &&
332
- (entry.type === 'api_key' || entry.type === 'oauth_token') &&
333
- typeof entry.value === 'string') {
334
- credentials[provider] = entry;
335
- }
336
- }
337
- return credentials;
338
- };
339
- // Credentials never live in a project directory or a shell profile — they
340
- // are written once by `stratus setup` and read on every run, 0600 so only
341
- // the owner can read them.
342
- const saveCredentials = async (env, credentials) => {
343
- const filePath = credentialsPath(env);
344
- await mkdir(path.dirname(filePath), { recursive: true });
345
- await writeFile(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
346
- await chmod(filePath, 0o600);
347
- };
348
105
  const readOptionValue = (tokens, index, flag) => {
349
106
  const value = tokens[index + 1];
350
107
  if (!value) {
@@ -391,13 +148,130 @@ export const parseCommand = (argv, env = {}) => {
391
148
  }
392
149
  return { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
393
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
+ }
394
268
  if (command === 'agent') {
395
269
  const [subcommand, ...agentRest] = rest;
396
270
  if (subcommand === '--help' || subcommand === '-h') {
397
271
  return { command: 'help' };
398
272
  }
399
273
  if (subcommand !== 'new') {
400
- 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`);
401
275
  }
402
276
  let name;
403
277
  let instructions;
@@ -598,349 +472,10 @@ export const formatEvent = (event) => {
598
472
  return null;
599
473
  }
600
474
  };
601
- const loadConfigFile = async (configPath) => {
602
- const raw = await readFile(configPath, 'utf8');
603
- const parsed = JSON.parse(raw);
604
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
605
- throw new Error(`Config file must contain a JSON object: ${configPath}`);
606
- }
607
- const config = parsed;
608
- const resolved = {};
609
- if (typeof config.provider === 'string') {
610
- resolved.provider = parseProviderName(config.provider, `config ${configPath}`);
611
- }
612
- if (typeof config.model === 'string' && config.model.length > 0) {
613
- resolved.model = config.model;
614
- }
615
- if (typeof config.baseUrl === 'string' && config.baseUrl.length > 0) {
616
- resolved.baseUrl = config.baseUrl;
617
- }
618
- if (typeof config.apiKeyEnv === 'string' && config.apiKeyEnv.length > 0) {
619
- resolved.apiKeyEnv = config.apiKeyEnv;
620
- }
621
- if (typeof config.systemPrompt === 'string' && config.systemPrompt.length > 0) {
622
- resolved.systemPrompt = config.systemPrompt;
623
- }
624
- if (typeof config.soul === 'string' && config.soul.length > 0) {
625
- resolved.soul = config.soul;
626
- }
627
- if (typeof config.fallbackModel === 'string' && config.fallbackModel.length > 0) {
628
- resolved.fallbackModel = config.fallbackModel;
629
- }
630
- if (typeof config.fallbackProvider === 'string') {
631
- resolved.fallbackProvider = parseProviderName(config.fallbackProvider, `config ${configPath}`);
632
- }
633
- if (typeof config.fallbackBaseUrl === 'string' && config.fallbackBaseUrl.length > 0) {
634
- resolved.fallbackBaseUrl = config.fallbackBaseUrl;
635
- }
636
- return resolved;
637
- };
638
- const resolveConfigLocation = async (command, env) => {
639
- const processEnv = readProcessEnv(env);
640
- const cwd = readWorkingDirectory(env);
641
- const explicit = command.configPath ?? processEnv.STRATUS_CONFIG ?? processEnv.STRATUSCLAW_CONFIG;
642
- if (explicit) {
643
- return { path: path.resolve(cwd, explicit), trusted: true };
644
- }
645
- // Project-local configs win; the global ~/.stratus/config.json written by
646
- // `stratus setup` is the fallback that makes the CLI work from anywhere.
647
- const candidates = [
648
- { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), trusted: false },
649
- { path: path.join(cwd, LEGACY_CONFIG_FILENAME), trusted: false },
650
- { path: globalConfigPath(env), trusted: true },
651
- ];
652
- for (const candidate of candidates) {
653
- try {
654
- await readFile(candidate.path, 'utf8');
655
- return candidate;
656
- }
657
- catch (error) {
658
- const code = error.code;
659
- if (code !== 'ENOENT') {
660
- throw error;
661
- }
662
- }
663
- }
664
- return undefined;
665
- };
666
- const readNonEmptyString = (value, map) => {
667
- if (typeof value !== 'string') {
668
- return undefined;
669
- }
670
- const trimmed = value.trim();
671
- if (trimmed.length === 0) {
672
- return undefined;
673
- }
674
- return map ? map(trimmed) : trimmed;
675
- };
676
- // A soul travels with the run: --soul outranks STRATUS_SOUL, which outranks
677
- // the config file's "soul" key.
678
- const resolveSoul = async (command, env, fileConfig) => {
679
- const processEnv = readProcessEnv(env);
680
- const soulPath = command.soul
681
- ?? readNonEmptyString(processEnv.STRATUS_SOUL)
682
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
683
- ?? fileConfig.soul;
684
- if (!soulPath) {
685
- return undefined;
686
- }
687
- const resolvedPath = path.resolve(readWorkingDirectory(env), String(soulPath));
688
- let raw;
689
- try {
690
- raw = await readFile(resolvedPath, 'utf8');
691
- }
692
- catch (error) {
693
- if (error.code === 'ENOENT') {
694
- throw new Error(`Soul file not found: ${resolvedPath}`);
695
- }
696
- throw error;
697
- }
698
- try {
699
- // Seeding with the resolved path keeps an unnamed soul's generated
700
- // identity (name, id, avatar) stable across runs — persisted memory is
701
- // keyed by that id, so it must not change between invocations.
702
- return parseSoul(raw, { seed: resolvedPath });
703
- }
704
- catch (error) {
705
- throw new Error(`Could not parse soul file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`);
706
- }
707
- };
708
- export const resolveRuntimeConfig = async (command, env = {}) => {
709
- const processEnv = readProcessEnv(env);
710
- const configLocation = await resolveConfigLocation(command, env);
711
- const fileConfig = configLocation ? await loadConfigFile(configLocation.path) : {};
712
- const soul = await resolveSoul(command, env, fileConfig);
713
- // Explicit flags and env vars outrank the soul's own provider/model hints,
714
- // which outrank the config file's defaults.
715
- const provider = command.provider
716
- ?? readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
717
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
718
- ?? readNonEmptyString(soul?.provider, (value) => parseProviderName(value, 'soul file'))
719
- ?? fileConfig.provider
720
- ?? 'demo';
721
- if (provider === 'demo') {
722
- return { provider: 'demo', ...(soul ? { soul } : {}) };
723
- }
724
- // A config file's model/baseUrl/apiKeyEnv were written for the provider
725
- // named in that file. When a flag, env var, or soul selects a different
726
- // provider, those values would point at the wrong API (e.g. an OpenAI
727
- // base URL handed to the Anthropic SDK), so they are ignored. A file with
728
- // no provider key predates the anthropic option, so its settings are
729
- // treated as openai-specific.
730
- const fileConfigApplies = (fileConfig.provider ?? 'openai') === provider;
731
- // A soul's model was chosen for the soul's own provider. If a flag or env
732
- // var overrides that provider, the model hint would target the wrong API
733
- // (e.g. a Claude model sent to OpenAI), so it only applies when the soul
734
- // names no provider or names the selected one.
735
- const soulModelApplies = soul?.provider === undefined || soul.provider === provider;
736
- const model = command.model
737
- ?? readNonEmptyString(processEnv.STRATUS_MODEL)
738
- ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
739
- ?? (soulModelApplies ? readNonEmptyString(soul?.model) : undefined)
740
- ?? (fileConfigApplies ? fileConfig.model : undefined)
741
- ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL);
742
- const apiKeyEnvName = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
743
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
744
- ?? (fileConfigApplies ? fileConfig.apiKeyEnv : undefined)
745
- ?? (provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
746
- const credentials = await loadCredentials(env);
747
- // A custom endpoint chosen by an auto-discovered project config is not a
748
- // place the stored sign-in ever gets sent — a cloned repository could
749
- // point it anywhere. Flags and env vars are the user's own choice, and
750
- // the provider's default endpoint is harmless.
751
- const defaultEndpointFor = (target) => target === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL;
752
- const untrustedCustomBaseUrl = configLocation?.trusted === false
753
- && command.baseUrl === undefined
754
- && readNonEmptyString(processEnv.STRATUS_BASE_URL) === undefined
755
- && readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL) === undefined
756
- && fileConfigApplies
757
- && fileConfig.baseUrl !== undefined
758
- && fileConfig.baseUrl.replace(/\/+$/, '') !== defaultEndpointFor(String(provider));
759
- // Env vars outrank the stored sign-in from `stratus setup`.
760
- const envApiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
761
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
762
- ?? readNonEmptyString(processEnv[String(apiKeyEnvName)]);
763
- const candidateCredential = credentials[provider];
764
- // A bound credential ignores config URLs entirely, so an untrusted
765
- // project URL cannot redirect it — only unbound stored keys are blocked.
766
- const credentialIsBound = candidateCredential?.type === 'api_key' && candidateCredential.baseUrl !== undefined;
767
- const storedCredential = envApiKey || (untrustedCustomBaseUrl && !credentialIsBound)
768
- ? undefined
769
- : candidateCredential;
770
- const apiKey = envApiKey
771
- ?? (storedCredential?.type === 'api_key' ? storedCredential.value : undefined);
772
- const authToken = provider === 'anthropic' && storedCredential?.type === 'oauth_token'
773
- ? storedCredential.value
774
- : undefined;
775
- // A stored key bound to an endpoint is used ONLY with that endpoint — a
776
- // config file can never redirect it, not even to the official default
777
- // URL (a project config could otherwise reroute a local-service key to
778
- // the official API). An explicit flag or env URL that disagrees refuses
779
- // the stored key instead of leaking it. This applies to both providers.
780
- const boundBaseUrl = !envApiKey && storedCredential?.type === 'api_key'
781
- ? storedCredential.baseUrl
782
- : undefined;
783
- const explicitBaseUrl = command.baseUrl
784
- ?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
785
- ?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL);
786
- if (boundBaseUrl && explicitBaseUrl
787
- && String(explicitBaseUrl).replace(/\/+$/, '') !== boundBaseUrl.replace(/\/+$/, '')) {
788
- 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.`);
789
- }
790
- const baseUrl = boundBaseUrl
791
- ?? explicitBaseUrl
792
- ?? (fileConfigApplies ? fileConfig.baseUrl : undefined)
793
- // The Anthropic SDK knows its own endpoint; only openai needs a default.
794
- ?? (provider === 'anthropic' ? undefined : DEFAULT_OPENAI_BASE_URL);
795
- if (!apiKey && !authToken) {
796
- if (untrustedCustomBaseUrl && credentials[provider]) {
797
- 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.`);
798
- }
799
- throw new Error(`Missing API key for provider=${provider}. Run \`stratus setup\` to sign in, or set STRATUS_API_KEY or ${apiKeyEnvName}.`);
800
- }
801
- const resolved = provider === 'anthropic'
802
- ? {
803
- provider: 'anthropic',
804
- model: String(model),
805
- ...(baseUrl ? { baseUrl: String(baseUrl) } : {}),
806
- ...(apiKey ? { apiKey: String(apiKey) } : {}),
807
- ...(authToken ? { authToken } : {}),
808
- }
809
- : {
810
- provider: 'openai',
811
- model: String(model),
812
- baseUrl: String(baseUrl),
813
- apiKey: String(apiKey),
814
- };
815
- const systemPrompt = readNonEmptyString(processEnv.STRATUS_SYSTEM_PROMPT)
816
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SYSTEM_PROMPT)
817
- ?? fileConfig.systemPrompt;
818
- if (systemPrompt) {
819
- resolved.systemPrompt = String(systemPrompt);
820
- }
821
- if (env.fetch) {
822
- resolved.fetch = env.fetch;
823
- }
824
- if (soul) {
825
- resolved.soul = soul;
826
- }
827
- // A configured fallback model kicks in when the default model errors
828
- // mid-run. It needs its own working sign-in; without one the fallback is
829
- // quietly skipped rather than failing the run it exists to rescue.
830
- // An implicit fallback (no fallbackProvider key) was written for the
831
- // config's own provider — when a flag, env var, or soul overrides that
832
- // provider, the fallback model would target the wrong API, so it is
833
- // ignored. An explicit fallbackProvider stays valid regardless.
834
- if (fileConfig.fallbackModel && (fileConfig.fallbackProvider !== undefined || fileConfigApplies)) {
835
- const fallbackProvider = fileConfig.fallbackProvider ?? provider;
836
- if (fallbackProvider !== 'demo') {
837
- // Same precedence as the primary sign-in: environment keys outrank
838
- // the stored credential. And the same endpoint rule: an untrusted
839
- // project config's custom fallback URL never receives a stored key —
840
- // including the primary's stored key when both share a provider.
841
- // Only env-supplied keys follow such a URL.
842
- const fallbackUntrustedUrl = fallbackProvider === 'openai'
843
- && configLocation?.trusted === false
844
- && fileConfig.fallbackBaseUrl !== undefined
845
- && fileConfig.fallbackBaseUrl.replace(/\/+$/, '') !== DEFAULT_OPENAI_BASE_URL;
846
- const fallbackEnvKey = readNonEmptyString(processEnv[fallbackProvider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY']);
847
- const fallbackCredential = fallbackEnvKey || fallbackUntrustedUrl ? undefined : credentials[fallbackProvider];
848
- const primaryReusable = fallbackProvider === provider
849
- && (envApiKey !== undefined || !fallbackUntrustedUrl);
850
- const fallbackApiKey = (primaryReusable ? apiKey : undefined)
851
- ?? fallbackEnvKey
852
- ?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.value : undefined);
853
- const fallbackAuthToken = fallbackProvider !== provider && fallbackProvider === 'anthropic' && fallbackCredential?.type === 'oauth_token'
854
- ? fallbackCredential.value
855
- : (primaryReusable ? authToken : undefined);
856
- if (fallbackApiKey || fallbackAuthToken) {
857
- // When the fallback key comes out of the credential store (its own
858
- // entry, or the primary's reused stored key), its bound endpoint is
859
- // authoritative — config URLs cannot redirect it.
860
- const fallbackBoundUrl = fallbackCredential?.type === 'api_key'
861
- ? fallbackCredential.baseUrl
862
- : (primaryReusable && !envApiKey ? boundBaseUrl : undefined);
863
- // An anthropic fallback on the same provider keeps the primary's
864
- // configured endpoint — retrying the same credential against the
865
- // official endpoint instead of the configured service would leak
866
- // it and likely fail.
867
- const fallbackAnthropicBaseUrl = fallbackProvider === 'anthropic'
868
- ? (fallbackProvider === provider && baseUrl ? String(baseUrl) : undefined)
869
- ?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.baseUrl : undefined)
870
- : undefined;
871
- resolved.fallback = {
872
- provider: fallbackProvider,
873
- model: fileConfig.fallbackModel,
874
- ...(fallbackProvider === 'openai'
875
- ? {
876
- baseUrl: fallbackBoundUrl
877
- ?? fileConfig.fallbackBaseUrl
878
- ?? (fallbackProvider === provider ? String(baseUrl) : undefined)
879
- ?? DEFAULT_OPENAI_BASE_URL,
880
- }
881
- : (fallbackAnthropicBaseUrl ? { baseUrl: fallbackAnthropicBaseUrl } : {})),
882
- ...(fallbackApiKey ? { apiKey: String(fallbackApiKey) } : {}),
883
- ...(fallbackAuthToken ? { authToken: fallbackAuthToken } : {}),
884
- };
885
- }
886
- }
887
- }
888
- return resolved;
889
- };
890
- // Wraps the fallback runtime as a provider: the primary model serves every
891
- // turn until it throws, then the run switches to the fallback for good.
892
- const createFallbackWrappedProvider = (primary, fallback, onFallback) => {
893
- let usingFallback = false;
894
- return {
895
- name: primary.name,
896
- async generate(request) {
897
- if (!usingFallback) {
898
- try {
899
- return await primary.generate(request);
900
- }
901
- catch (error) {
902
- usingFallback = true;
903
- onFallback(error);
904
- }
905
- }
906
- return fallback.generate(request);
907
- },
908
- };
909
- };
910
- const createRuntimeProvider = (config, onFallback) => {
911
- if (config.provider === 'demo') {
912
- return createDemoProvider();
913
- }
914
- if (config.fallback) {
915
- const { fallback, ...primaryConfig } = config;
916
- const primary = createRuntimeProvider(primaryConfig);
917
- const fallbackProvider = createRuntimeProvider({
918
- ...fallback,
919
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
920
- ...(config.fetch ? { fetch: config.fetch } : {}),
921
- });
922
- return createFallbackWrappedProvider(primary, fallbackProvider, onFallback ?? (() => { }));
923
- }
924
- if (config.provider === 'anthropic') {
925
- return createAnthropicProvider({
926
- model: config.model,
927
- ...(config.apiKey ? { apiKey: config.apiKey } : {}),
928
- ...(config.authToken ? { authToken: config.authToken } : {}),
929
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
930
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
931
- ...(config.fetch ? { fetch: config.fetch } : {}),
932
- });
933
- }
934
- return createOpenAICompatibleProvider({
935
- name: 'openai',
936
- model: config.model,
937
- apiKey: config.apiKey,
938
- baseUrl: config.baseUrl,
939
- ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
940
- ...(config.fetch ? { fetch: config.fetch } : {}),
941
- });
942
- };
943
- 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) => {
944
479
  if (mode === 'always') {
945
480
  return new AllowAllApprovalPolicy();
946
481
  }
@@ -951,6 +486,16 @@ const createApprovalPolicy = (mode, streams, env) => {
951
486
  },
952
487
  };
953
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
+ }
954
499
  return {
955
500
  async approve({ call }) {
956
501
  const input = env.approvalInput ?? process.stdin;
@@ -971,10 +516,14 @@ const createApprovalPolicy = (mode, streams, env) => {
971
516
  },
972
517
  };
973
518
  };
974
- 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) => {
975
524
  const runEnv = options.env ?? {};
976
525
  await migrateLegacyMemory(runEnv);
977
- const memory = createFileMemoryStore(memoryFilePath(runEnv));
526
+ const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(runEnv)));
978
527
  const tools = new ToolRegistry();
979
528
  tools.register(createDemoTool());
980
529
  tools.register(createRememberTool(memory));
@@ -983,7 +532,13 @@ export const runSingleLoop = async (prompt, streams, options) => {
983
532
  writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
984
533
  },
985
534
  });
986
- 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) {
987
542
  bus.subscribe(async (event) => {
988
543
  const line = formatEvent(event);
989
544
  if (line) {
@@ -991,56 +546,49 @@ export const runSingleLoop = async (prompt, streams, options) => {
991
546
  }
992
547
  });
993
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;
994
553
  const runtimeProvider = createRuntimeProvider(options.runtime, (error) => {
995
554
  const fallback = options.runtime.provider === 'demo' ? undefined : options.runtime.fallback;
996
555
  writeLine(streams.stderr, `Warning: the default model failed (${error instanceof Error ? error.message : String(error)}); falling back to ${fallback?.model ?? 'the fallback model'}.`);
997
- });
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);
998
562
  const runner = new AgentRunner({
999
563
  provider: runtimeProvider,
1000
564
  tools,
1001
565
  executor: createLocalCommandExecutor(),
1002
- approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
566
+ approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
1003
567
  bus,
1004
568
  memory,
1005
569
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
1006
570
  });
571
+ hostedRunner = runner;
1007
572
  await runner.initialize();
1008
- // A soul is a full identity — it replaces the built-in per-provider agent.
1009
- const agent = options.runtime.soul?.agent
1010
- ?? (options.runtime.provider === 'demo'
1011
- ? {
1012
- id: 'demo-agent',
1013
- name: 'Demo Agent',
1014
- instructions: 'Keep the loop tiny and readable.',
1015
- }
1016
- : options.runtime.provider === 'anthropic'
1017
- ? {
1018
- id: 'anthropic-agent',
1019
- name: 'Claude Agent',
1020
- instructions: 'Respond clearly and directly to the user request.',
1021
- }
1022
- : {
1023
- id: 'openai-agent',
1024
- name: 'OpenAI Agent',
1025
- instructions: 'Respond clearly and directly to the user request.',
1026
- });
573
+ // A soul is a full identity — without one, every provider serves the
574
+ // same built-in Stratus persona.
575
+ const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
1027
576
  const metadata = options.runtime.provider === 'demo'
1028
- ? { provider: 'demo' }
577
+ ? { provider: 'demo', executor: 'local-command' }
1029
578
  : {
1030
579
  provider: options.runtime.provider,
1031
580
  model: options.runtime.model,
1032
581
  ...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
1033
582
  };
583
+ return { runner, agent, metadata };
584
+ };
585
+ export const runSingleLoop = async (prompt, streams, options) => {
586
+ const { runner, agent, metadata } = await createAgentRuntime(streams, options);
1034
587
  return runner.run({
1035
588
  sessionId: randomUUID(),
1036
589
  agent,
1037
590
  userMessage: prompt,
1038
- metadata: options.runtime.provider === 'demo'
1039
- ? {
1040
- ...metadata,
1041
- executor: 'local-command',
1042
- }
1043
- : metadata,
591
+ metadata,
1044
592
  });
1045
593
  };
1046
594
  export const printSessionSummary = (session, streams) => {
@@ -1060,6 +608,219 @@ export const printSessionSummary = (session, streams) => {
1060
608
  writeLine(streams.stdout, `[${message.role}${nameSuffix}] ${content}`);
1061
609
  }
1062
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
+ };
1063
824
  const formatRuntimeBanner = (runtime) => {
1064
825
  const soulSuffix = runtime.soul ? ` as ${runtime.soul.agent.name}` : '';
1065
826
  if (runtime.provider === 'demo') {
@@ -1208,7 +969,7 @@ export const startDashboardServer = async (options) => {
1208
969
  sendJson(response, 200, {
1209
970
  ok: true,
1210
971
  service: 'stratus-dashboard',
1211
- version: '0.2.1',
972
+ version: CLI_VERSION,
1212
973
  now: new Date().toISOString(),
1213
974
  });
1214
975
  return;
@@ -1264,7 +1025,19 @@ export const startDashboardServer = async (options) => {
1264
1025
  // Printed commands must survive copy-paste into a shell, so anything outside
1265
1026
  // the safe character set gets single-quoted.
1266
1027
  const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
1267
- const createSetupPrompter = (streams, env) => {
1028
+ // The fixed header drawn at the top of every interactive screen.
1029
+ const stratusHeaderLines = () => {
1030
+ const title = 'Stratus Agent';
1031
+ const version = `v${CLI_VERSION}`;
1032
+ const gap = ' ';
1033
+ const content = ` ${title}${gap}${version} `;
1034
+ return [
1035
+ `\u001b[2m╭${'─'.repeat(content.length)}╮\u001b[0m`,
1036
+ `\u001b[2m│\u001b[0m \u001b[1m${title}\u001b[0m${gap}\u001b[2m${version}\u001b[0m \u001b[2m│\u001b[0m`,
1037
+ `\u001b[2m╰${'─'.repeat(content.length)}╯\u001b[0m`,
1038
+ ];
1039
+ };
1040
+ const createSetupPrompter = (streams, env, view) => {
1268
1041
  // On a real TTY, menus are arrow-key driven and secrets are read without
1269
1042
  // echo; with piped input (tests, scripts) everything is plain lines.
1270
1043
  const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
@@ -1273,7 +1046,7 @@ const createSetupPrompter = (streams, env) => {
1273
1046
  process.stdin.once('end', () => {
1274
1047
  closed = true;
1275
1048
  });
1276
- const question = (prompt, secret) => {
1049
+ const question = (prompt, secret, prefill) => {
1277
1050
  const readline = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1278
1051
  if (secret) {
1279
1052
  const internal = readline;
@@ -1298,8 +1071,27 @@ const createSetupPrompter = (streams, env) => {
1298
1071
  }
1299
1072
  resolve(answer.trim());
1300
1073
  });
1074
+ if (prefill) {
1075
+ // Pre-typed and editable: backspace over it or press Enter to keep.
1076
+ readline.write(prefill);
1077
+ }
1301
1078
  });
1302
1079
  };
1080
+ // Each menu replaces the screen: clear, draw the header, carry over the
1081
+ // most recent status lines, then the menu itself.
1082
+ const drawScreen = (out) => {
1083
+ out.write('\u001b[2J\u001b[H');
1084
+ for (const line of view?.header() ?? []) {
1085
+ out.write(`${line}\n`);
1086
+ }
1087
+ const notices = view?.consumeNotices() ?? [];
1088
+ if (notices.length > 0) {
1089
+ for (const line of notices) {
1090
+ out.write(`\u001b[2m${line}\u001b[0m\n`);
1091
+ }
1092
+ out.write('\n');
1093
+ }
1094
+ };
1303
1095
  const selectInteractive = (heading, options, opts) => new Promise((resolve) => {
1304
1096
  const stdin = process.stdin;
1305
1097
  const out = streams.stdout;
@@ -1312,7 +1104,10 @@ const createSetupPrompter = (streams, env) => {
1312
1104
  out.write(`\u001b[${options.length}A`);
1313
1105
  }
1314
1106
  else {
1315
- out.write(`${heading}\n`);
1107
+ drawScreen(out);
1108
+ if (heading.length > 0) {
1109
+ out.write(`${heading}\n`);
1110
+ }
1316
1111
  if (opts.footnote) {
1317
1112
  out.write(`${opts.footnote}\n`);
1318
1113
  }
@@ -1431,8 +1226,9 @@ const createSetupPrompter = (streams, env) => {
1431
1226
  stdin.on('data', onData);
1432
1227
  });
1433
1228
  return {
1434
- ask: (q) => question(q, false),
1229
+ ask: (q, opts) => question(q, false, opts?.prefill),
1435
1230
  askSecret: (q) => question(q, true),
1231
+ isInteractive: () => true,
1436
1232
  async select(heading, options, opts = {}) {
1437
1233
  if (options.length === 0) {
1438
1234
  return { kind: 'back' };
@@ -1510,15 +1306,17 @@ const createSetupPrompter = (streams, env) => {
1510
1306
  }
1511
1307
  };
1512
1308
  return {
1513
- async ask(q) {
1309
+ async ask(q, opts) {
1514
1310
  streams.stdout.write(q);
1515
- return nextLine();
1311
+ const line = await nextLine();
1312
+ return line || (opts?.prefill ?? '');
1516
1313
  },
1517
1314
  async askSecret(q) {
1518
1315
  // Piped input never echoes, so plain reads are safe here.
1519
1316
  streams.stdout.write(q);
1520
1317
  return nextLine();
1521
1318
  },
1319
+ isInteractive: () => false,
1522
1320
  async select(heading, options, opts = {}) {
1523
1321
  if (options.length === 0) {
1524
1322
  return { kind: 'back' };
@@ -1607,6 +1405,31 @@ export const runSetup = async (command, streams, env = {}) => {
1607
1405
  writeLine(streams.stderr, `Warning: could not read ${configPath} (${error instanceof Error ? error.message : String(error)}); starting fresh.`);
1608
1406
  }
1609
1407
  }
1408
+ // Interactive mode is a screen-based interface: every menu clears and
1409
+ // redraws under a fixed header, and status lines printed between menus
1410
+ // are carried onto the next screen (dimmed) so nothing is missed.
1411
+ const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
1412
+ const baseStreams = streams;
1413
+ const recentNotices = [];
1414
+ if (interactive) {
1415
+ streams = {
1416
+ stderr: baseStreams.stderr,
1417
+ stdout: {
1418
+ write(chunk) {
1419
+ for (const raw of String(chunk).split('\n')) {
1420
+ const line = raw.trimEnd();
1421
+ if (line.trim().length > 0) {
1422
+ recentNotices.push(line);
1423
+ if (recentNotices.length > 8) {
1424
+ recentNotices.shift();
1425
+ }
1426
+ }
1427
+ }
1428
+ return baseStreams.stdout.write(chunk);
1429
+ },
1430
+ },
1431
+ };
1432
+ }
1610
1433
  const state = {
1611
1434
  provider: existing.provider ?? 'anthropic',
1612
1435
  ...(existing.model ? { model: existing.model } : {}),
@@ -1624,7 +1447,10 @@ export const runSetup = async (command, streams, env = {}) => {
1624
1447
  credentials: await loadCredentials(env),
1625
1448
  credentialsDirty: false,
1626
1449
  };
1627
- const prompter = createSetupPrompter(streams, env);
1450
+ const prompter = createSetupPrompter(baseStreams, env, {
1451
+ header: stratusHeaderLines,
1452
+ consumeNotices: () => recentNotices.splice(0),
1453
+ });
1628
1454
  const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
1629
1455
  const defaultKeyEnvFor = (provider) => provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
1630
1456
  const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
@@ -2227,12 +2053,16 @@ export const runSetup = async (command, streams, env = {}) => {
2227
2053
  const session = await runSingleLoop('Say hello and introduce yourself in one short sentence.', streams, { events: false, runtime, env });
2228
2054
  printSessionSummary(session, streams);
2229
2055
  writeLine(streams.stdout);
2056
+ if (prompter.isInteractive()) {
2057
+ await prompter.ask('Press Enter to return to the menu… ');
2058
+ recentNotices.length = 0;
2059
+ }
2230
2060
  }
2231
2061
  catch (error) {
2232
2062
  const message = error instanceof Error ? error.message : String(error);
2233
2063
  writeLine(streams.stdout, `Test run failed: ${message}`);
2234
- if (runtime.provider === 'anthropic' && runtime.authToken && /\b(401|403|429)\b/.test(message)) {
2235
- writeLine(streams.stdout, 'Subscription setup tokens are built for the Claude Code integration, which Stratus does not route through yet — sign in with an Anthropic API key instead (Providers menu → Claude → API key).');
2064
+ if (runtime.provider === 'anthropic' && runtime.authToken && /Claude Code|\b(401|403|429)\b/.test(message)) {
2065
+ writeLine(streams.stdout, 'Subscription runs go through Claude Code. Make sure it is installed (npm install -g @anthropic-ai/claude-code) and signed in (run `claude`), or sign in with an Anthropic API key instead (Providers menu → Claude → API key).');
2236
2066
  }
2237
2067
  else if (/\b429\b/.test(message)) {
2238
2068
  writeLine(streams.stdout, 'A 429 means the provider rate-limited the request. On a new Anthropic account this usually means no purchased credits yet — check console.anthropic.com → Billing and Limits, then try again.');
@@ -2367,8 +2197,10 @@ export const runSetup = async (command, streams, env = {}) => {
2367
2197
  writeLine(streams.stdout, ' stratus dashboard');
2368
2198
  };
2369
2199
  try {
2370
- writeLine(streams.stdout, 'Stratus Agent setup');
2371
- writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
2200
+ if (!interactive) {
2201
+ writeLine(streams.stdout, 'Stratus Agent setup');
2202
+ writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
2203
+ }
2372
2204
  if (envConfigVar && !command.configPath) {
2373
2205
  writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
2374
2206
  }
@@ -2405,7 +2237,264 @@ export const runSetup = async (command, streams, env = {}) => {
2405
2237
  prompter.close();
2406
2238
  }
2407
2239
  };
2408
- export const runAgentNew = (command, streams) => {
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
+ };
2384
+ export const runAgentNew = async (command, streams, env = {}) => {
2385
+ // On a real terminal, creating an agent is the same guided experience as
2386
+ // setup: a headed screen, a prefilled (editable) name, a personality, and
2387
+ // an offer to make them the default. Scripted formats and piped input
2388
+ // keep the plain one-shot output.
2389
+ const interactive = env.setupInput === undefined
2390
+ && process.stdin.isTTY === true
2391
+ && command.format === 'text';
2392
+ if (interactive) {
2393
+ const prompter = createSetupPrompter(streams, env, {
2394
+ header: stratusHeaderLines,
2395
+ consumeNotices: () => [],
2396
+ });
2397
+ try {
2398
+ streams.stdout.write('\u001b[2J\u001b[H');
2399
+ for (const line of stratusHeaderLines()) {
2400
+ writeLine(streams.stdout, line);
2401
+ }
2402
+ writeLine(streams.stdout);
2403
+ const suggested = command.name ?? generateAgentName();
2404
+ const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
2405
+ const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
2406
+ const persona = instructions || DEFAULT_SOUL_STARTER;
2407
+ let agent = defineAgent({ name, instructions: persona });
2408
+ // Frontmatter pins what a run from this directory would actually use:
2409
+ // env vars outrank the active config file (project-local or explicit
2410
+ // first, global otherwise), and no provider anywhere falls back to
2411
+ // demo — the exact precedence of stratus run. Demo produces no pin
2412
+ // at all: the soul keeps following the machine's configuration
2413
+ // instead of demanding credentials nothing has signed in for.
2414
+ const processEnv = readProcessEnv(env);
2415
+ // Creating an agent must not be blocked by a broken config — it only
2416
+ // feeds the soul's provider/model hint, so fall back to defaults.
2417
+ const { location: configLocation, config: activeConfig } = await discoverActiveConfig(env, (message) => {
2418
+ writeLine(streams.stdout, `Note: ${message}.`);
2419
+ });
2420
+ const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
2421
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
2422
+ ?? activeConfig.provider
2423
+ ?? 'demo';
2424
+ // The active config's model was written for the provider named in
2425
+ // that config; it only travels into the soul when they still match.
2426
+ const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
2427
+ const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
2428
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
2429
+ ?? (configModelApplies ? activeConfig.model : undefined)
2430
+ ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
2431
+ const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
2432
+ // The name stays theirs, but the id — the soul filename and the
2433
+ // memory key — must be unique: the suggestion pool is small, so a
2434
+ // repeat name would otherwise silently overwrite an earlier agent's
2435
+ // soul and share their memory. 'wx' makes each claim atomic; on a
2436
+ // collision the id gets a fresh suffix and we try again.
2437
+ await mkdir(agentsDirPath(env), { recursive: true });
2438
+ const baseId = agent.id;
2439
+ let soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2440
+ for (;;) {
2441
+ try {
2442
+ await writeFile(soulPath, formatSoul({ agent, ...soulPin }), { flag: 'wx' });
2443
+ break;
2444
+ }
2445
+ catch (error) {
2446
+ if (error.code !== 'EEXIST') {
2447
+ throw error;
2448
+ }
2449
+ agent = defineAgent({ id: `${baseId}-${randomUUID().slice(0, 4)}`, name, instructions: persona });
2450
+ soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2451
+ }
2452
+ }
2453
+ const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
2454
+ 'Yes — every stratus run talks to them',
2455
+ 'Not now',
2456
+ ]);
2457
+ let madeDefault = false;
2458
+ if (makeDefault.kind === 'index' && makeDefault.index === 0) {
2459
+ // The default agent is a machine-wide setting, so it lands in the
2460
+ // global config even when a project config is active here.
2461
+ let globalConfig;
2462
+ try {
2463
+ globalConfig = await loadConfigFile(globalConfigPath(env));
2464
+ }
2465
+ catch (error) {
2466
+ if (error.code === 'ENOENT') {
2467
+ globalConfig = {};
2468
+ }
2469
+ else {
2470
+ // A malformed config is recoverable by hand — never overwrite it.
2471
+ writeLine(streams.stdout, `Could not read ${globalConfigPath(env)} (${error instanceof Error ? error.message : String(error)}), so it was left untouched. Fix it, then make ${agent.name} the default from stratus setup.`);
2472
+ }
2473
+ }
2474
+ if (globalConfig !== undefined) {
2475
+ const config = { ...globalConfig, provider: globalConfig.provider ?? soulProvider, soul: soulPath };
2476
+ await mkdir(path.dirname(globalConfigPath(env)), { recursive: true });
2477
+ await writeFile(globalConfigPath(env), `${JSON.stringify(config, null, 2)}\n`);
2478
+ madeDefault = true;
2479
+ if (configLocation && configLocation.path !== globalConfigPath(env)) {
2480
+ writeLine(streams.stdout, `Note: ${configLocation.path} takes precedence over the global config for runs started in this directory.`);
2481
+ }
2482
+ }
2483
+ }
2484
+ writeLine(streams.stdout);
2485
+ writeLine(streams.stdout, `Say hello to ${agent.name}.`);
2486
+ writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
2487
+ writeLine(streams.stdout);
2488
+ writeLine(streams.stdout, 'Try:');
2489
+ writeLine(streams.stdout, madeDefault
2490
+ ? ' stratus run "introduce yourself"'
2491
+ : ` stratus run --soul ${quoteShellArg(soulPath)} "introduce yourself"`);
2492
+ return 0;
2493
+ }
2494
+ finally {
2495
+ prompter.close();
2496
+ }
2497
+ }
2409
2498
  const agent = defineAgent({
2410
2499
  ...(command.name ? { name: command.name } : {}),
2411
2500
  ...(command.instructions ? { instructions: command.instructions } : {}),
@@ -2459,6 +2548,119 @@ export const runDashboard = async (command, streams, env = {}) => {
2459
2548
  await handle.close();
2460
2549
  return 0;
2461
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
+ };
2462
2664
  export const runCli = async ({ argv, streams = process, env = {} }) => {
2463
2665
  try {
2464
2666
  const resolvedEnv = argv.includes('--stdin') && env.stdin === undefined
@@ -2473,7 +2675,13 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2473
2675
  return 0;
2474
2676
  }
2475
2677
  if (command.command === 'agent-new') {
2476
- return runAgentNew(command, streams);
2678
+ return runAgentNew(command, streams, resolvedEnv);
2679
+ }
2680
+ if (command.command === 'agents') {
2681
+ return runAgents(command, streams, resolvedEnv);
2682
+ }
2683
+ if (command.command === 'chat') {
2684
+ return runChat(command, streams, resolvedEnv);
2477
2685
  }
2478
2686
  if (command.command === 'setup') {
2479
2687
  return runSetup(command, streams, resolvedEnv);
@@ -2481,6 +2689,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2481
2689
  if (command.command === 'dashboard') {
2482
2690
  return runDashboard(command, streams, resolvedEnv);
2483
2691
  }
2692
+ if (command.command === 'serve') {
2693
+ return runServe(command, streams, resolvedEnv);
2694
+ }
2484
2695
  const runtime = await resolveRuntimeConfig(command, resolvedEnv);
2485
2696
  if (command.format === 'text') {
2486
2697
  writeLine(streams.stdout, formatRuntimeBanner(runtime));