@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/README.md +6 -3
- package/dist/index.d.ts +42 -50
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +864 -653
- package/dist/index.js.map +1 -1
- package/package.json +19 -7
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 {
|
|
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,
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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.
|
|
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 —
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 &&
|
|
2235
|
-
writeLine(streams.stdout, 'Subscription
|
|
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
|
-
|
|
2371
|
-
|
|
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
|
-
|
|
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));
|