@stratusagent/cli 0.2.2 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/index.d.ts +41 -50
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +664 -661
- package/dist/index.js.map +1 -1
- package/package.json +19 -8
package/dist/index.js
CHANGED
|
@@ -1,54 +1,28 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
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 { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
13
|
-
import { createClaudeCodeProvider } from '@stratusagent/provider-claude-code';
|
|
9
|
+
import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
10
|
+
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
14
11
|
import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
15
|
-
|
|
16
|
-
// The
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
instructions: 'You are Stratus, a personal agent on the Stratus Agent platform. Be warm, direct, and concise. When asked who or what you are, you are Stratus — a Stratus agent — regardless of which model is serving the conversation.',
|
|
22
|
-
};
|
|
23
|
-
// Unsouled runs used to remember facts under a per-provider default agent.
|
|
24
|
-
// Stratus inherits all of them: reads for the built-in agent also return
|
|
25
|
-
// entries stored under the legacy ids, while new facts land under 'stratus'.
|
|
26
|
-
const LEGACY_DEFAULT_AGENT_IDS = ['demo-agent', 'anthropic-agent', 'openai-agent'];
|
|
27
|
-
const withLegacyDefaultMemories = (store) => ({
|
|
28
|
-
append: (agentId, content, metadata) => store.append(agentId, content, metadata),
|
|
29
|
-
async list(agentId) {
|
|
30
|
-
if (agentId !== DEFAULT_STRATUS_AGENT.id) {
|
|
31
|
-
return store.list(agentId);
|
|
32
|
-
}
|
|
33
|
-
const batches = await Promise.all([DEFAULT_STRATUS_AGENT.id, ...LEGACY_DEFAULT_AGENT_IDS].map((id) => store.list(id)));
|
|
34
|
-
return batches.flat().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
const DEFAULT_CONFIG_FILENAME = 'stratus.config.json';
|
|
38
|
-
const LEGACY_CONFIG_FILENAME = 'stratusclaw.config.json';
|
|
39
|
-
const STRATUS_HOME_DIRNAME = '.stratus';
|
|
40
|
-
const GLOBAL_CONFIG_FILENAME = 'config.json';
|
|
41
|
-
const CREDENTIALS_FILENAME = 'credentials.json';
|
|
42
|
-
const AGENTS_DIRNAME = 'agents';
|
|
43
|
-
const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
|
|
44
|
-
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
45
|
-
const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini';
|
|
12
|
+
import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveRuntimeConfig as resolveStateRuntimeConfig, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
|
|
13
|
+
// The shared state package owns config resolution, credentials, memory, and
|
|
14
|
+
// provider wiring now (the gateway uses the same code); the CLI re-exports
|
|
15
|
+
// its historical surface so existing importers keep working.
|
|
16
|
+
export { createFileMemoryStore } from '@stratusagent/state';
|
|
17
|
+
export const CLI_VERSION = '0.2.5';
|
|
46
18
|
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
47
19
|
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
48
20
|
const HELP_TEXT = `Stratus Agent CLI
|
|
49
21
|
|
|
50
22
|
Usage:
|
|
51
23
|
stratus setup
|
|
24
|
+
stratus chat
|
|
25
|
+
stratus chat --soul ./examples/souls/ava.md
|
|
52
26
|
stratus run --prompt "Use the demo tool"
|
|
53
27
|
stratus run "Say hello"
|
|
54
28
|
ANTHROPIC_API_KEY=... stratus run --provider anthropic "Say hello"
|
|
@@ -56,6 +30,7 @@ Usage:
|
|
|
56
30
|
echo "Use the echo tool" | stratus run --stdin
|
|
57
31
|
STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
|
|
58
32
|
stratus run --config ./stratus.config.json --provider openai "Say hello"
|
|
33
|
+
stratus agents
|
|
59
34
|
stratus dashboard
|
|
60
35
|
stratus dashboard --port 4123 --host 0.0.0.0 --no-open
|
|
61
36
|
|
|
@@ -64,8 +39,16 @@ Commands:
|
|
|
64
39
|
subscription or API key), create your agent, and test it —
|
|
65
40
|
settings go to ~/.stratus/config.json, sign-ins to
|
|
66
41
|
~/.stratus/credentials.json (0600)
|
|
42
|
+
chat Talk with your agent — the conversation persists across turns
|
|
43
|
+
and remembered facts accumulate; /exit or Ctrl+C to leave
|
|
67
44
|
run Execute one local Stratus Agent session
|
|
45
|
+
serve Run stratusd, the always-on gateway: durable sessions, the
|
|
46
|
+
whole roster live at once (each agent on its own provider),
|
|
47
|
+
delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
|
|
48
|
+
(--idle-timeout <seconds>, --no-events, --config <path>)
|
|
68
49
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
50
|
+
agents List your agents: who they are, where their souls live, what
|
|
51
|
+
they run on, what they remember (also: stratus agent list)
|
|
69
52
|
dashboard Start the local Stratus Agent dashboard and open it in your browser
|
|
70
53
|
help Show this help message
|
|
71
54
|
|
|
@@ -110,207 +93,6 @@ const stringifyValue = (value) => {
|
|
|
110
93
|
}
|
|
111
94
|
return JSON.stringify(value, null, 2);
|
|
112
95
|
};
|
|
113
|
-
const createDemoTool = () => defineLocalCommandTool({
|
|
114
|
-
name: 'demo.echo',
|
|
115
|
-
description: 'Return a tiny transformed summary for CLI demos through a real local process.',
|
|
116
|
-
parameters: {
|
|
117
|
-
type: 'object',
|
|
118
|
-
properties: {
|
|
119
|
-
text: { type: 'string', description: 'Text to echo back through the local process.' },
|
|
120
|
-
},
|
|
121
|
-
required: ['text'],
|
|
122
|
-
},
|
|
123
|
-
async createCommand(input) {
|
|
124
|
-
const text = typeof input.text === 'string' ? input.text : '';
|
|
125
|
-
const normalized = text.trim() || 'empty input';
|
|
126
|
-
const script = `const text = ${JSON.stringify(normalized)}; console.log(JSON.stringify({ received: text, uppercase: text.toUpperCase(), length: text.length }));`;
|
|
127
|
-
return {
|
|
128
|
-
command: process.execPath,
|
|
129
|
-
args: ['-e', script],
|
|
130
|
-
};
|
|
131
|
-
},
|
|
132
|
-
parseResult(result) {
|
|
133
|
-
return JSON.parse(result.stdout);
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
const createDemoProvider = () => defineProvider({
|
|
137
|
-
name: 'demo',
|
|
138
|
-
async generate({ session }) {
|
|
139
|
-
const builder = createProviderResponseBuilder();
|
|
140
|
-
const lastMessage = session.messages.at(-1);
|
|
141
|
-
if (lastMessage?.role === 'tool') {
|
|
142
|
-
const result = lastMessage.toolResult;
|
|
143
|
-
if (result?.ok) {
|
|
144
|
-
builder.addText(`The demo.echo tool finished with: ${JSON.stringify(result.output)}`);
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
builder.addText(`The demo.echo tool did not run (${result?.error ?? 'unknown error'}), so this run ends here.`);
|
|
148
|
-
}
|
|
149
|
-
return builder.done();
|
|
150
|
-
}
|
|
151
|
-
const prompt = [...session.messages].reverse().find((message) => message.role === 'user')?.content?.trim() ?? '';
|
|
152
|
-
const wantsTool = /\b(tool|echo|uppercase|inspect)\b/i.test(prompt);
|
|
153
|
-
builder.addText(`Demo provider ready. Prompt received: ${prompt || '(empty)'}`);
|
|
154
|
-
if (wantsTool) {
|
|
155
|
-
builder.addToolCall({
|
|
156
|
-
id: `${session.id}:call:demo-echo`,
|
|
157
|
-
toolName: 'demo.echo',
|
|
158
|
-
input: { text: prompt },
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
builder.addText('No tool call was needed, so this run stays text-only. Mention “tool” or “echo” to trigger the demo tool.');
|
|
163
|
-
}
|
|
164
|
-
return builder.done();
|
|
165
|
-
},
|
|
166
|
-
});
|
|
167
|
-
const MEMORY_FILENAME = 'memory.jsonl';
|
|
168
|
-
const memoryFilePath = (env) => path.join(stratusHomePath(env), MEMORY_FILENAME);
|
|
169
|
-
// Memory used to live under the working directory. Fold any such file into
|
|
170
|
-
// the global store the first time a run happens from that directory, then
|
|
171
|
-
// archive it — an upgrade must never look like the agent forgot.
|
|
172
|
-
//
|
|
173
|
-
// Every import first takes exclusive ownership by atomically renaming its
|
|
174
|
-
// source to a unique claim file: of any competing processes, exactly one
|
|
175
|
-
// wins the rename and the rest see ENOENT. A crash mid-import leaves the
|
|
176
|
-
// claim file behind; later runs re-claim it the same way and finish the
|
|
177
|
-
// job, with entries deduped against the global store by id. Only records
|
|
178
|
-
// that parse as real memory entries are imported — malformed lines stay in
|
|
179
|
-
// the archive instead of poisoning the global store for every agent.
|
|
180
|
-
const isMemoryEntryLine = (line) => {
|
|
181
|
-
try {
|
|
182
|
-
const parsed = JSON.parse(line);
|
|
183
|
-
return typeof parsed === 'object' && parsed !== null
|
|
184
|
-
&& typeof parsed.id === 'string'
|
|
185
|
-
&& typeof parsed.agentId === 'string'
|
|
186
|
-
&& typeof parsed.content === 'string';
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
const migrateLegacyMemory = async (env) => {
|
|
193
|
-
const legacyPath = path.join(readWorkingDirectory(env), '.stratus', MEMORY_FILENAME);
|
|
194
|
-
const globalPath = memoryFilePath(env);
|
|
195
|
-
if (legacyPath === globalPath) {
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
const legacyDir = path.dirname(legacyPath);
|
|
199
|
-
const archivePath = `${legacyPath}.migrated`;
|
|
200
|
-
const claimAndImport = async (sourcePath) => {
|
|
201
|
-
const claimPath = path.join(legacyDir, `${MEMORY_FILENAME}.migrating-${randomUUID()}`);
|
|
202
|
-
try {
|
|
203
|
-
await rename(sourcePath, claimPath);
|
|
204
|
-
}
|
|
205
|
-
catch (error) {
|
|
206
|
-
if (error.code === 'ENOENT') {
|
|
207
|
-
return; // another process owns it, or there is nothing to migrate
|
|
208
|
-
}
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
211
|
-
const claimed = await readFile(claimPath, 'utf8');
|
|
212
|
-
let existingIds;
|
|
213
|
-
try {
|
|
214
|
-
existingIds = new Set((await readFile(globalPath, 'utf8'))
|
|
215
|
-
.split('\n')
|
|
216
|
-
.filter(isMemoryEntryLine)
|
|
217
|
-
.map((line) => JSON.parse(line).id));
|
|
218
|
-
}
|
|
219
|
-
catch {
|
|
220
|
-
existingIds = new Set();
|
|
221
|
-
}
|
|
222
|
-
const entries = claimed
|
|
223
|
-
.split('\n')
|
|
224
|
-
.filter((line) => line.trim().length > 0)
|
|
225
|
-
.filter(isMemoryEntryLine)
|
|
226
|
-
.filter((line) => !existingIds.has(JSON.parse(line).id));
|
|
227
|
-
if (entries.length > 0) {
|
|
228
|
-
await mkdir(path.dirname(globalPath), { recursive: true });
|
|
229
|
-
await appendFile(globalPath, `${entries.join('\n')}\n`);
|
|
230
|
-
}
|
|
231
|
-
// Archive by appending (never overwriting an earlier archive), then
|
|
232
|
-
// drop the claim — its content is fully preserved in the archive.
|
|
233
|
-
if (claimed.length > 0) {
|
|
234
|
-
await appendFile(archivePath, claimed.endsWith('\n') || claimed.length === 0 ? claimed : `${claimed}\n`);
|
|
235
|
-
}
|
|
236
|
-
await unlink(claimPath);
|
|
237
|
-
};
|
|
238
|
-
await claimAndImport(legacyPath);
|
|
239
|
-
// Finish any claims a crashed run left behind (both the current unique
|
|
240
|
-
// names and the fixed .migrating name from earlier versions).
|
|
241
|
-
let leftovers;
|
|
242
|
-
try {
|
|
243
|
-
leftovers = await readdir(legacyDir);
|
|
244
|
-
}
|
|
245
|
-
catch (error) {
|
|
246
|
-
if (error.code === 'ENOENT') {
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
throw error;
|
|
250
|
-
}
|
|
251
|
-
for (const name of leftovers) {
|
|
252
|
-
if (name.startsWith(`${MEMORY_FILENAME}.migrating`)) {
|
|
253
|
-
await claimAndImport(path.join(legacyDir, name));
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
// Agents keep the same memory across CLI runs: every remembered fact lands
|
|
258
|
-
// in ~/.stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
|
|
259
|
-
// tomorrow — from any directory — is the Ava you talked to today. One JSON
|
|
260
|
-
// entry per line, written with O_APPEND: concurrent runs each add their own
|
|
261
|
-
// line instead of re-writing the file, so no run can clobber another's
|
|
262
|
-
// remembered fact.
|
|
263
|
-
export const createFileMemoryStore = (filePath) => {
|
|
264
|
-
const readEntries = async () => {
|
|
265
|
-
let raw;
|
|
266
|
-
try {
|
|
267
|
-
raw = await readFile(filePath, 'utf8');
|
|
268
|
-
}
|
|
269
|
-
catch (error) {
|
|
270
|
-
if (error.code === 'ENOENT') {
|
|
271
|
-
return [];
|
|
272
|
-
}
|
|
273
|
-
throw error;
|
|
274
|
-
}
|
|
275
|
-
return raw
|
|
276
|
-
.split('\n')
|
|
277
|
-
.filter((line) => line.trim().length > 0)
|
|
278
|
-
.map((line) => {
|
|
279
|
-
try {
|
|
280
|
-
return JSON.parse(line);
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
throw new Error(`Memory file has an invalid line: ${filePath}`);
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
};
|
|
287
|
-
return {
|
|
288
|
-
async append(agentId, content, metadata) {
|
|
289
|
-
const entry = {
|
|
290
|
-
id: `${agentId}:memory:${randomUUID()}`,
|
|
291
|
-
agentId,
|
|
292
|
-
content,
|
|
293
|
-
createdAt: new Date().toISOString(),
|
|
294
|
-
...(metadata ? { metadata } : {}),
|
|
295
|
-
};
|
|
296
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
297
|
-
await appendFile(filePath, `${JSON.stringify(entry)}\n`);
|
|
298
|
-
return entry;
|
|
299
|
-
},
|
|
300
|
-
async list(agentId) {
|
|
301
|
-
// Dedupe by id at read time: even if a rare race double-imported a
|
|
302
|
-
// fact, the agent only ever sees it once.
|
|
303
|
-
const seen = new Set();
|
|
304
|
-
return (await readEntries()).filter((entry) => {
|
|
305
|
-
if (entry.agentId !== agentId || seen.has(entry.id)) {
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
seen.add(entry.id);
|
|
309
|
-
return true;
|
|
310
|
-
});
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
|
-
};
|
|
314
96
|
const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
|
|
315
97
|
const readPromptFromStdin = async (stdin) => {
|
|
316
98
|
stdin.setEncoding('utf8');
|
|
@@ -320,54 +102,6 @@ const readPromptFromStdin = async (stdin) => {
|
|
|
320
102
|
}
|
|
321
103
|
return data.trim();
|
|
322
104
|
};
|
|
323
|
-
const parseProviderName = (value, label) => {
|
|
324
|
-
if (value === 'demo' || value === 'openai' || value === 'anthropic') {
|
|
325
|
-
return value;
|
|
326
|
-
}
|
|
327
|
-
throw new Error(`Unsupported provider in ${label}: ${value}`);
|
|
328
|
-
};
|
|
329
|
-
const readProcessEnv = (env) => env.processEnv ?? process.env;
|
|
330
|
-
const readWorkingDirectory = (env) => env.cwd ?? process.cwd();
|
|
331
|
-
const readHomeDirectory = (env) => env.homeDir ?? os.homedir();
|
|
332
|
-
const stratusHomePath = (env) => path.join(readHomeDirectory(env), STRATUS_HOME_DIRNAME);
|
|
333
|
-
const globalConfigPath = (env) => path.join(stratusHomePath(env), GLOBAL_CONFIG_FILENAME);
|
|
334
|
-
const credentialsPath = (env) => path.join(stratusHomePath(env), CREDENTIALS_FILENAME);
|
|
335
|
-
const agentsDirPath = (env) => path.join(stratusHomePath(env), AGENTS_DIRNAME);
|
|
336
|
-
const loadCredentials = async (env) => {
|
|
337
|
-
let raw;
|
|
338
|
-
try {
|
|
339
|
-
raw = await readFile(credentialsPath(env), 'utf8');
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
if (error.code === 'ENOENT') {
|
|
343
|
-
return {};
|
|
344
|
-
}
|
|
345
|
-
throw error;
|
|
346
|
-
}
|
|
347
|
-
const parsed = JSON.parse(raw);
|
|
348
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
349
|
-
throw new Error(`Credentials file must contain a JSON object: ${credentialsPath(env)}`);
|
|
350
|
-
}
|
|
351
|
-
const credentials = {};
|
|
352
|
-
for (const provider of ['anthropic', 'openai']) {
|
|
353
|
-
const entry = parsed[provider];
|
|
354
|
-
if (typeof entry === 'object' && entry !== null && !Array.isArray(entry) &&
|
|
355
|
-
(entry.type === 'api_key' || entry.type === 'oauth_token') &&
|
|
356
|
-
typeof entry.value === 'string') {
|
|
357
|
-
credentials[provider] = entry;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return credentials;
|
|
361
|
-
};
|
|
362
|
-
// Credentials never live in a project directory or a shell profile — they
|
|
363
|
-
// are written once by `stratus setup` and read on every run, 0600 so only
|
|
364
|
-
// the owner can read them.
|
|
365
|
-
const saveCredentials = async (env, credentials) => {
|
|
366
|
-
const filePath = credentialsPath(env);
|
|
367
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
368
|
-
await writeFile(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
369
|
-
await chmod(filePath, 0o600);
|
|
370
|
-
};
|
|
371
105
|
const readOptionValue = (tokens, index, flag) => {
|
|
372
106
|
const value = tokens[index + 1];
|
|
373
107
|
if (!value) {
|
|
@@ -414,13 +148,130 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
414
148
|
}
|
|
415
149
|
return { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
|
|
416
150
|
}
|
|
151
|
+
if (command === 'serve') {
|
|
152
|
+
const parsed = { command: 'serve', events: true };
|
|
153
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
154
|
+
const token = rest[index];
|
|
155
|
+
if (!token) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (token === '--help' || token === '-h') {
|
|
159
|
+
return { command: 'help' };
|
|
160
|
+
}
|
|
161
|
+
if (token === '--config') {
|
|
162
|
+
parsed.configPath = readOptionValue(rest, index, '--config');
|
|
163
|
+
index += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (token === '--idle-timeout') {
|
|
167
|
+
const seconds = Number(readOptionValue(rest, index, '--idle-timeout'));
|
|
168
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
169
|
+
throw new Error(`Invalid value for --idle-timeout: ${rest[index + 1] ?? '(missing)'}`);
|
|
170
|
+
}
|
|
171
|
+
parsed.idleTimeoutMs = Math.round(seconds * 1000);
|
|
172
|
+
index += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (token === '--no-events') {
|
|
176
|
+
parsed.events = false;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Unknown option: ${token}`);
|
|
180
|
+
}
|
|
181
|
+
return parsed;
|
|
182
|
+
}
|
|
183
|
+
if (command === 'chat') {
|
|
184
|
+
const parsed = { command: 'chat', events: false, approvals: 'always' };
|
|
185
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
186
|
+
const token = rest[index];
|
|
187
|
+
if (!token) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (token === '--help' || token === '-h') {
|
|
191
|
+
return { command: 'help' };
|
|
192
|
+
}
|
|
193
|
+
if (token === '--provider') {
|
|
194
|
+
parsed.provider = parseProviderName(readOptionValue(rest, index, '--provider'), '--provider');
|
|
195
|
+
index += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (token === '--model') {
|
|
199
|
+
parsed.model = readOptionValue(rest, index, '--model');
|
|
200
|
+
index += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (token === '--base-url') {
|
|
204
|
+
parsed.baseUrl = readOptionValue(rest, index, '--base-url');
|
|
205
|
+
index += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (token === '--soul') {
|
|
209
|
+
parsed.soul = readOptionValue(rest, index, '--soul');
|
|
210
|
+
index += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (token === '--config') {
|
|
214
|
+
parsed.configPath = readOptionValue(rest, index, '--config');
|
|
215
|
+
index += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (token === '--approvals') {
|
|
219
|
+
const value = readOptionValue(rest, index, '--approvals');
|
|
220
|
+
if (value !== 'always' && value !== 'ask' && value !== 'never') {
|
|
221
|
+
throw new Error(`Invalid value for --approvals: ${value}`);
|
|
222
|
+
}
|
|
223
|
+
parsed.approvals = value;
|
|
224
|
+
index += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (token === '--max-turns') {
|
|
228
|
+
const value = Number(readOptionValue(rest, index, '--max-turns'));
|
|
229
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
230
|
+
throw new Error(`Invalid value for --max-turns: ${rest[index + 1] ?? '(missing)'}`);
|
|
231
|
+
}
|
|
232
|
+
parsed.maxTurns = value;
|
|
233
|
+
index += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (token === '--events') {
|
|
237
|
+
parsed.events = true;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
throw new Error(`Unknown option: ${token}`);
|
|
241
|
+
}
|
|
242
|
+
return parsed;
|
|
243
|
+
}
|
|
244
|
+
if (command === 'agents' || (command === 'agent' && rest[0] === 'list')) {
|
|
245
|
+
const agentsRest = command === 'agents' ? rest : rest.slice(1);
|
|
246
|
+
let format = 'text';
|
|
247
|
+
for (let index = 0; index < agentsRest.length; index += 1) {
|
|
248
|
+
const token = agentsRest[index];
|
|
249
|
+
if (!token) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (token === '--help' || token === '-h') {
|
|
253
|
+
return { command: 'help' };
|
|
254
|
+
}
|
|
255
|
+
if (token === '--format') {
|
|
256
|
+
const value = readOptionValue(agentsRest, index, '--format');
|
|
257
|
+
if (value !== 'text' && value !== 'json') {
|
|
258
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
259
|
+
}
|
|
260
|
+
format = value;
|
|
261
|
+
index += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`Unknown option: ${token}`);
|
|
265
|
+
}
|
|
266
|
+
return { command: 'agents', format };
|
|
267
|
+
}
|
|
417
268
|
if (command === 'agent') {
|
|
418
269
|
const [subcommand, ...agentRest] = rest;
|
|
419
270
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
420
271
|
return { command: 'help' };
|
|
421
272
|
}
|
|
422
273
|
if (subcommand !== 'new') {
|
|
423
|
-
throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new`);
|
|
274
|
+
throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new, stratus agent list`);
|
|
424
275
|
}
|
|
425
276
|
let name;
|
|
426
277
|
let instructions;
|
|
@@ -621,359 +472,10 @@ export const formatEvent = (event) => {
|
|
|
621
472
|
return null;
|
|
622
473
|
}
|
|
623
474
|
};
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
throw new Error(`Config file must contain a JSON object: ${configPath}`);
|
|
629
|
-
}
|
|
630
|
-
const config = parsed;
|
|
631
|
-
const resolved = {};
|
|
632
|
-
if (typeof config.provider === 'string') {
|
|
633
|
-
resolved.provider = parseProviderName(config.provider, `config ${configPath}`);
|
|
634
|
-
}
|
|
635
|
-
if (typeof config.model === 'string' && config.model.length > 0) {
|
|
636
|
-
resolved.model = config.model;
|
|
637
|
-
}
|
|
638
|
-
if (typeof config.baseUrl === 'string' && config.baseUrl.length > 0) {
|
|
639
|
-
resolved.baseUrl = config.baseUrl;
|
|
640
|
-
}
|
|
641
|
-
if (typeof config.apiKeyEnv === 'string' && config.apiKeyEnv.length > 0) {
|
|
642
|
-
resolved.apiKeyEnv = config.apiKeyEnv;
|
|
643
|
-
}
|
|
644
|
-
if (typeof config.systemPrompt === 'string' && config.systemPrompt.length > 0) {
|
|
645
|
-
resolved.systemPrompt = config.systemPrompt;
|
|
646
|
-
}
|
|
647
|
-
if (typeof config.soul === 'string' && config.soul.length > 0) {
|
|
648
|
-
resolved.soul = config.soul;
|
|
649
|
-
}
|
|
650
|
-
if (typeof config.fallbackModel === 'string' && config.fallbackModel.length > 0) {
|
|
651
|
-
resolved.fallbackModel = config.fallbackModel;
|
|
652
|
-
}
|
|
653
|
-
if (typeof config.fallbackProvider === 'string') {
|
|
654
|
-
resolved.fallbackProvider = parseProviderName(config.fallbackProvider, `config ${configPath}`);
|
|
655
|
-
}
|
|
656
|
-
if (typeof config.fallbackBaseUrl === 'string' && config.fallbackBaseUrl.length > 0) {
|
|
657
|
-
resolved.fallbackBaseUrl = config.fallbackBaseUrl;
|
|
658
|
-
}
|
|
659
|
-
return resolved;
|
|
660
|
-
};
|
|
661
|
-
const resolveConfigLocation = async (command, env) => {
|
|
662
|
-
const processEnv = readProcessEnv(env);
|
|
663
|
-
const cwd = readWorkingDirectory(env);
|
|
664
|
-
const explicit = command.configPath ?? processEnv.STRATUS_CONFIG ?? processEnv.STRATUSCLAW_CONFIG;
|
|
665
|
-
if (explicit) {
|
|
666
|
-
return { path: path.resolve(cwd, explicit), trusted: true };
|
|
667
|
-
}
|
|
668
|
-
// Project-local configs win; the global ~/.stratus/config.json written by
|
|
669
|
-
// `stratus setup` is the fallback that makes the CLI work from anywhere.
|
|
670
|
-
const candidates = [
|
|
671
|
-
{ path: path.join(cwd, DEFAULT_CONFIG_FILENAME), trusted: false },
|
|
672
|
-
{ path: path.join(cwd, LEGACY_CONFIG_FILENAME), trusted: false },
|
|
673
|
-
{ path: globalConfigPath(env), trusted: true },
|
|
674
|
-
];
|
|
675
|
-
for (const candidate of candidates) {
|
|
676
|
-
try {
|
|
677
|
-
await readFile(candidate.path, 'utf8');
|
|
678
|
-
return candidate;
|
|
679
|
-
}
|
|
680
|
-
catch (error) {
|
|
681
|
-
const code = error.code;
|
|
682
|
-
if (code !== 'ENOENT') {
|
|
683
|
-
throw error;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
return undefined;
|
|
688
|
-
};
|
|
689
|
-
const readNonEmptyString = (value, map) => {
|
|
690
|
-
if (typeof value !== 'string') {
|
|
691
|
-
return undefined;
|
|
692
|
-
}
|
|
693
|
-
const trimmed = value.trim();
|
|
694
|
-
if (trimmed.length === 0) {
|
|
695
|
-
return undefined;
|
|
696
|
-
}
|
|
697
|
-
return map ? map(trimmed) : trimmed;
|
|
698
|
-
};
|
|
699
|
-
// A soul travels with the run: --soul outranks STRATUS_SOUL, which outranks
|
|
700
|
-
// the config file's "soul" key.
|
|
701
|
-
const resolveSoul = async (command, env, fileConfig) => {
|
|
702
|
-
const processEnv = readProcessEnv(env);
|
|
703
|
-
const soulPath = command.soul
|
|
704
|
-
?? readNonEmptyString(processEnv.STRATUS_SOUL)
|
|
705
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
|
|
706
|
-
?? fileConfig.soul;
|
|
707
|
-
if (!soulPath) {
|
|
708
|
-
return undefined;
|
|
709
|
-
}
|
|
710
|
-
const resolvedPath = path.resolve(readWorkingDirectory(env), String(soulPath));
|
|
711
|
-
let raw;
|
|
712
|
-
try {
|
|
713
|
-
raw = await readFile(resolvedPath, 'utf8');
|
|
714
|
-
}
|
|
715
|
-
catch (error) {
|
|
716
|
-
if (error.code === 'ENOENT') {
|
|
717
|
-
throw new Error(`Soul file not found: ${resolvedPath}`);
|
|
718
|
-
}
|
|
719
|
-
throw error;
|
|
720
|
-
}
|
|
721
|
-
try {
|
|
722
|
-
// Seeding with the resolved path keeps an unnamed soul's generated
|
|
723
|
-
// identity (name, id, avatar) stable across runs — persisted memory is
|
|
724
|
-
// keyed by that id, so it must not change between invocations.
|
|
725
|
-
return parseSoul(raw, { seed: resolvedPath });
|
|
726
|
-
}
|
|
727
|
-
catch (error) {
|
|
728
|
-
throw new Error(`Could not parse soul file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
export const resolveRuntimeConfig = async (command, env = {}) => {
|
|
732
|
-
const processEnv = readProcessEnv(env);
|
|
733
|
-
const configLocation = await resolveConfigLocation(command, env);
|
|
734
|
-
const fileConfig = configLocation ? await loadConfigFile(configLocation.path) : {};
|
|
735
|
-
const soul = await resolveSoul(command, env, fileConfig);
|
|
736
|
-
// Explicit flags and env vars outrank the soul's own provider/model hints,
|
|
737
|
-
// which outrank the config file's defaults.
|
|
738
|
-
const provider = command.provider
|
|
739
|
-
?? readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
740
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
|
|
741
|
-
?? readNonEmptyString(soul?.provider, (value) => parseProviderName(value, 'soul file'))
|
|
742
|
-
?? fileConfig.provider
|
|
743
|
-
?? 'demo';
|
|
744
|
-
if (provider === 'demo') {
|
|
745
|
-
return { provider: 'demo', ...(soul ? { soul } : {}) };
|
|
746
|
-
}
|
|
747
|
-
// A config file's model/baseUrl/apiKeyEnv were written for the provider
|
|
748
|
-
// named in that file. When a flag, env var, or soul selects a different
|
|
749
|
-
// provider, those values would point at the wrong API (e.g. an OpenAI
|
|
750
|
-
// base URL handed to the Anthropic SDK), so they are ignored. A file with
|
|
751
|
-
// no provider key predates the anthropic option, so its settings are
|
|
752
|
-
// treated as openai-specific.
|
|
753
|
-
const fileConfigApplies = (fileConfig.provider ?? 'openai') === provider;
|
|
754
|
-
// A soul's model was chosen for the soul's own provider. If a flag or env
|
|
755
|
-
// var overrides that provider, the model hint would target the wrong API
|
|
756
|
-
// (e.g. a Claude model sent to OpenAI), so it only applies when the soul
|
|
757
|
-
// names no provider or names the selected one.
|
|
758
|
-
const soulModelApplies = soul?.provider === undefined || soul.provider === provider;
|
|
759
|
-
const model = command.model
|
|
760
|
-
?? readNonEmptyString(processEnv.STRATUS_MODEL)
|
|
761
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
|
|
762
|
-
?? (soulModelApplies ? readNonEmptyString(soul?.model) : undefined)
|
|
763
|
-
?? (fileConfigApplies ? fileConfig.model : undefined)
|
|
764
|
-
?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL);
|
|
765
|
-
const apiKeyEnvName = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
766
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
767
|
-
?? (fileConfigApplies ? fileConfig.apiKeyEnv : undefined)
|
|
768
|
-
?? (provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
|
|
769
|
-
const credentials = await loadCredentials(env);
|
|
770
|
-
// A custom endpoint chosen by an auto-discovered project config is not a
|
|
771
|
-
// place the stored sign-in ever gets sent — a cloned repository could
|
|
772
|
-
// point it anywhere. Flags and env vars are the user's own choice, and
|
|
773
|
-
// the provider's default endpoint is harmless.
|
|
774
|
-
const defaultEndpointFor = (target) => target === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL;
|
|
775
|
-
const untrustedCustomBaseUrl = configLocation?.trusted === false
|
|
776
|
-
&& command.baseUrl === undefined
|
|
777
|
-
&& readNonEmptyString(processEnv.STRATUS_BASE_URL) === undefined
|
|
778
|
-
&& readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL) === undefined
|
|
779
|
-
&& fileConfigApplies
|
|
780
|
-
&& fileConfig.baseUrl !== undefined
|
|
781
|
-
&& fileConfig.baseUrl.replace(/\/+$/, '') !== defaultEndpointFor(String(provider));
|
|
782
|
-
// Env vars outrank the stored sign-in from `stratus setup`.
|
|
783
|
-
const envApiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
784
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
|
|
785
|
-
?? readNonEmptyString(processEnv[String(apiKeyEnvName)]);
|
|
786
|
-
const candidateCredential = credentials[provider];
|
|
787
|
-
// A bound credential ignores config URLs entirely, so an untrusted
|
|
788
|
-
// project URL cannot redirect it — only unbound stored keys are blocked.
|
|
789
|
-
const credentialIsBound = candidateCredential?.type === 'api_key' && candidateCredential.baseUrl !== undefined;
|
|
790
|
-
const storedCredential = envApiKey || (untrustedCustomBaseUrl && !credentialIsBound)
|
|
791
|
-
? undefined
|
|
792
|
-
: candidateCredential;
|
|
793
|
-
const apiKey = envApiKey
|
|
794
|
-
?? (storedCredential?.type === 'api_key' ? storedCredential.value : undefined);
|
|
795
|
-
const authToken = provider === 'anthropic' && storedCredential?.type === 'oauth_token'
|
|
796
|
-
? storedCredential.value
|
|
797
|
-
: undefined;
|
|
798
|
-
// A stored key bound to an endpoint is used ONLY with that endpoint — a
|
|
799
|
-
// config file can never redirect it, not even to the official default
|
|
800
|
-
// URL (a project config could otherwise reroute a local-service key to
|
|
801
|
-
// the official API). An explicit flag or env URL that disagrees refuses
|
|
802
|
-
// the stored key instead of leaking it. This applies to both providers.
|
|
803
|
-
const boundBaseUrl = !envApiKey && storedCredential?.type === 'api_key'
|
|
804
|
-
? storedCredential.baseUrl
|
|
805
|
-
: undefined;
|
|
806
|
-
const explicitBaseUrl = command.baseUrl
|
|
807
|
-
?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
|
|
808
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL);
|
|
809
|
-
if (boundBaseUrl && explicitBaseUrl
|
|
810
|
-
&& String(explicitBaseUrl).replace(/\/+$/, '') !== boundBaseUrl.replace(/\/+$/, '')) {
|
|
811
|
-
throw new Error(`Your saved ${provider} sign-in is bound to ${boundBaseUrl} and is not sent to ${explicitBaseUrl}. Set ${apiKeyEnvName} or STRATUS_API_KEY to use that endpoint.`);
|
|
812
|
-
}
|
|
813
|
-
const baseUrl = boundBaseUrl
|
|
814
|
-
?? explicitBaseUrl
|
|
815
|
-
?? (fileConfigApplies ? fileConfig.baseUrl : undefined)
|
|
816
|
-
// The Anthropic SDK knows its own endpoint; only openai needs a default.
|
|
817
|
-
?? (provider === 'anthropic' ? undefined : DEFAULT_OPENAI_BASE_URL);
|
|
818
|
-
if (!apiKey && !authToken) {
|
|
819
|
-
if (untrustedCustomBaseUrl && credentials[provider]) {
|
|
820
|
-
throw new Error(`The project config at ${configLocation?.path} sets a custom base URL (${fileConfig.baseUrl}), so your saved sign-in is not sent to it. Set ${apiKeyEnvName} or STRATUS_API_KEY to use this endpoint, or run with --config to trust the file explicitly.`);
|
|
821
|
-
}
|
|
822
|
-
throw new Error(`Missing API key for provider=${provider}. Run \`stratus setup\` to sign in, or set STRATUS_API_KEY or ${apiKeyEnvName}.`);
|
|
823
|
-
}
|
|
824
|
-
const resolved = provider === 'anthropic'
|
|
825
|
-
? {
|
|
826
|
-
provider: 'anthropic',
|
|
827
|
-
model: String(model),
|
|
828
|
-
...(baseUrl ? { baseUrl: String(baseUrl) } : {}),
|
|
829
|
-
...(apiKey ? { apiKey: String(apiKey) } : {}),
|
|
830
|
-
...(authToken ? { authToken } : {}),
|
|
831
|
-
}
|
|
832
|
-
: {
|
|
833
|
-
provider: 'openai',
|
|
834
|
-
model: String(model),
|
|
835
|
-
baseUrl: String(baseUrl),
|
|
836
|
-
apiKey: String(apiKey),
|
|
837
|
-
};
|
|
838
|
-
const systemPrompt = readNonEmptyString(processEnv.STRATUS_SYSTEM_PROMPT)
|
|
839
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_SYSTEM_PROMPT)
|
|
840
|
-
?? fileConfig.systemPrompt;
|
|
841
|
-
if (systemPrompt) {
|
|
842
|
-
resolved.systemPrompt = String(systemPrompt);
|
|
843
|
-
}
|
|
844
|
-
if (env.fetch) {
|
|
845
|
-
resolved.fetch = env.fetch;
|
|
846
|
-
}
|
|
847
|
-
if (soul) {
|
|
848
|
-
resolved.soul = soul;
|
|
849
|
-
}
|
|
850
|
-
// A configured fallback model kicks in when the default model errors
|
|
851
|
-
// mid-run. It needs its own working sign-in; without one the fallback is
|
|
852
|
-
// quietly skipped rather than failing the run it exists to rescue.
|
|
853
|
-
// An implicit fallback (no fallbackProvider key) was written for the
|
|
854
|
-
// config's own provider — when a flag, env var, or soul overrides that
|
|
855
|
-
// provider, the fallback model would target the wrong API, so it is
|
|
856
|
-
// ignored. An explicit fallbackProvider stays valid regardless.
|
|
857
|
-
if (fileConfig.fallbackModel && (fileConfig.fallbackProvider !== undefined || fileConfigApplies)) {
|
|
858
|
-
const fallbackProvider = fileConfig.fallbackProvider ?? provider;
|
|
859
|
-
if (fallbackProvider !== 'demo') {
|
|
860
|
-
// Same precedence as the primary sign-in: environment keys outrank
|
|
861
|
-
// the stored credential. And the same endpoint rule: an untrusted
|
|
862
|
-
// project config's custom fallback URL never receives a stored key —
|
|
863
|
-
// including the primary's stored key when both share a provider.
|
|
864
|
-
// Only env-supplied keys follow such a URL.
|
|
865
|
-
const fallbackUntrustedUrl = fallbackProvider === 'openai'
|
|
866
|
-
&& configLocation?.trusted === false
|
|
867
|
-
&& fileConfig.fallbackBaseUrl !== undefined
|
|
868
|
-
&& fileConfig.fallbackBaseUrl.replace(/\/+$/, '') !== DEFAULT_OPENAI_BASE_URL;
|
|
869
|
-
const fallbackEnvKey = readNonEmptyString(processEnv[fallbackProvider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY']);
|
|
870
|
-
const fallbackCredential = fallbackEnvKey || fallbackUntrustedUrl ? undefined : credentials[fallbackProvider];
|
|
871
|
-
const primaryReusable = fallbackProvider === provider
|
|
872
|
-
&& (envApiKey !== undefined || !fallbackUntrustedUrl);
|
|
873
|
-
const fallbackApiKey = (primaryReusable ? apiKey : undefined)
|
|
874
|
-
?? fallbackEnvKey
|
|
875
|
-
?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.value : undefined);
|
|
876
|
-
const fallbackAuthToken = fallbackProvider !== provider && fallbackProvider === 'anthropic' && fallbackCredential?.type === 'oauth_token'
|
|
877
|
-
? fallbackCredential.value
|
|
878
|
-
: (primaryReusable ? authToken : undefined);
|
|
879
|
-
if (fallbackApiKey || fallbackAuthToken) {
|
|
880
|
-
// When the fallback key comes out of the credential store (its own
|
|
881
|
-
// entry, or the primary's reused stored key), its bound endpoint is
|
|
882
|
-
// authoritative — config URLs cannot redirect it.
|
|
883
|
-
const fallbackBoundUrl = fallbackCredential?.type === 'api_key'
|
|
884
|
-
? fallbackCredential.baseUrl
|
|
885
|
-
: (primaryReusable && !envApiKey ? boundBaseUrl : undefined);
|
|
886
|
-
// An anthropic fallback on the same provider keeps the primary's
|
|
887
|
-
// configured endpoint — retrying the same credential against the
|
|
888
|
-
// official endpoint instead of the configured service would leak
|
|
889
|
-
// it and likely fail.
|
|
890
|
-
const fallbackAnthropicBaseUrl = fallbackProvider === 'anthropic'
|
|
891
|
-
? (fallbackProvider === provider && baseUrl ? String(baseUrl) : undefined)
|
|
892
|
-
?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.baseUrl : undefined)
|
|
893
|
-
: undefined;
|
|
894
|
-
resolved.fallback = {
|
|
895
|
-
provider: fallbackProvider,
|
|
896
|
-
model: fileConfig.fallbackModel,
|
|
897
|
-
...(fallbackProvider === 'openai'
|
|
898
|
-
? {
|
|
899
|
-
baseUrl: fallbackBoundUrl
|
|
900
|
-
?? fileConfig.fallbackBaseUrl
|
|
901
|
-
?? (fallbackProvider === provider ? String(baseUrl) : undefined)
|
|
902
|
-
?? DEFAULT_OPENAI_BASE_URL,
|
|
903
|
-
}
|
|
904
|
-
: (fallbackAnthropicBaseUrl ? { baseUrl: fallbackAnthropicBaseUrl } : {})),
|
|
905
|
-
...(fallbackApiKey ? { apiKey: String(fallbackApiKey) } : {}),
|
|
906
|
-
...(fallbackAuthToken ? { authToken: fallbackAuthToken } : {}),
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
return resolved;
|
|
912
|
-
};
|
|
913
|
-
// Wraps the fallback runtime as a provider: the primary model serves every
|
|
914
|
-
// turn until it throws, then the run switches to the fallback for good.
|
|
915
|
-
const createFallbackWrappedProvider = (primary, fallback, onFallback) => {
|
|
916
|
-
let usingFallback = false;
|
|
917
|
-
return {
|
|
918
|
-
name: primary.name,
|
|
919
|
-
async generate(request) {
|
|
920
|
-
if (!usingFallback) {
|
|
921
|
-
try {
|
|
922
|
-
return await primary.generate(request);
|
|
923
|
-
}
|
|
924
|
-
catch (error) {
|
|
925
|
-
usingFallback = true;
|
|
926
|
-
onFallback(error);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
return fallback.generate(request);
|
|
930
|
-
},
|
|
931
|
-
};
|
|
932
|
-
};
|
|
933
|
-
const createRuntimeProvider = (config, onFallback) => {
|
|
934
|
-
if (config.provider === 'demo') {
|
|
935
|
-
return createDemoProvider();
|
|
936
|
-
}
|
|
937
|
-
if (config.fallback) {
|
|
938
|
-
const { fallback, ...primaryConfig } = config;
|
|
939
|
-
const primary = createRuntimeProvider(primaryConfig);
|
|
940
|
-
const fallbackProvider = createRuntimeProvider({
|
|
941
|
-
...fallback,
|
|
942
|
-
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
943
|
-
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
944
|
-
});
|
|
945
|
-
return createFallbackWrappedProvider(primary, fallbackProvider, onFallback ?? (() => { }));
|
|
946
|
-
}
|
|
947
|
-
if (config.provider === 'anthropic') {
|
|
948
|
-
// Subscription setup tokens are only honored inside the Claude Code
|
|
949
|
-
// harness, so they route through the Agent SDK runtime; API keys use
|
|
950
|
-
// the raw Messages API.
|
|
951
|
-
if (config.authToken && !config.apiKey) {
|
|
952
|
-
return createClaudeCodeProvider({
|
|
953
|
-
authToken: config.authToken,
|
|
954
|
-
model: config.model,
|
|
955
|
-
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
956
|
-
});
|
|
957
|
-
}
|
|
958
|
-
return createAnthropicProvider({
|
|
959
|
-
model: config.model,
|
|
960
|
-
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
961
|
-
...(config.authToken ? { authToken: config.authToken } : {}),
|
|
962
|
-
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
963
|
-
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
964
|
-
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
return createOpenAICompatibleProvider({
|
|
968
|
-
name: 'openai',
|
|
969
|
-
model: config.model,
|
|
970
|
-
apiKey: config.apiKey,
|
|
971
|
-
baseUrl: config.baseUrl,
|
|
972
|
-
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
973
|
-
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
974
|
-
});
|
|
975
|
-
};
|
|
976
|
-
const createApprovalPolicy = (mode, streams, env) => {
|
|
475
|
+
// Kept with its historical CLI signature: a parsed run command is a
|
|
476
|
+
// RuntimeSelection plus CLI-only fields the resolver ignores.
|
|
477
|
+
export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
|
|
478
|
+
const createApprovalPolicy = (mode, streams, env, ask) => {
|
|
977
479
|
if (mode === 'always') {
|
|
978
480
|
return new AllowAllApprovalPolicy();
|
|
979
481
|
}
|
|
@@ -984,6 +486,16 @@ const createApprovalPolicy = (mode, streams, env) => {
|
|
|
984
486
|
},
|
|
985
487
|
};
|
|
986
488
|
}
|
|
489
|
+
// A caller that already owns stdin (chat's readline) supplies its own
|
|
490
|
+
// asker — two readers on one stream would race for the same bytes.
|
|
491
|
+
if (ask) {
|
|
492
|
+
return {
|
|
493
|
+
async approve({ call }) {
|
|
494
|
+
const answer = await ask(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
|
|
495
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
987
499
|
return {
|
|
988
500
|
async approve({ call }) {
|
|
989
501
|
const input = env.approvalInput ?? process.stdin;
|
|
@@ -1004,7 +516,11 @@ const createApprovalPolicy = (mode, streams, env) => {
|
|
|
1004
516
|
},
|
|
1005
517
|
};
|
|
1006
518
|
};
|
|
1007
|
-
|
|
519
|
+
// The shared wiring behind every conversational command: memory (with
|
|
520
|
+
// legacy migration), tools, events, provider (with fallback), and the
|
|
521
|
+
// agent the runtime resolved. run uses it for one shot; chat keeps the
|
|
522
|
+
// runner alive and resumes the same session turn after turn.
|
|
523
|
+
const createAgentRuntime = async (streams, options) => {
|
|
1008
524
|
const runEnv = options.env ?? {};
|
|
1009
525
|
await migrateLegacyMemory(runEnv);
|
|
1010
526
|
const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(runEnv)));
|
|
@@ -1016,7 +532,13 @@ export const runSingleLoop = async (prompt, streams, options) => {
|
|
|
1016
532
|
writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1017
533
|
},
|
|
1018
534
|
});
|
|
1019
|
-
if (options.
|
|
535
|
+
if (options.onEvent) {
|
|
536
|
+
const onEvent = options.onEvent;
|
|
537
|
+
bus.subscribe(async (event) => {
|
|
538
|
+
onEvent(event);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
else if (options.events ?? true) {
|
|
1020
542
|
bus.subscribe(async (event) => {
|
|
1021
543
|
const line = formatEvent(event);
|
|
1022
544
|
if (line) {
|
|
@@ -1024,40 +546,49 @@ export const runSingleLoop = async (prompt, streams, options) => {
|
|
|
1024
546
|
}
|
|
1025
547
|
});
|
|
1026
548
|
}
|
|
549
|
+
// The Claude Code runtime executes kernel tools by calling back into the
|
|
550
|
+
// runner built just below — late-bound because the runner needs the
|
|
551
|
+
// provider first.
|
|
552
|
+
let hostedRunner;
|
|
1027
553
|
const runtimeProvider = createRuntimeProvider(options.runtime, (error) => {
|
|
1028
554
|
const fallback = options.runtime.provider === 'demo' ? undefined : options.runtime.fallback;
|
|
1029
555
|
writeLine(streams.stderr, `Warning: the default model failed (${error instanceof Error ? error.message : String(error)}); falling back to ${fallback?.model ?? 'the fallback model'}.`);
|
|
1030
|
-
})
|
|
556
|
+
}, async (session, call, context) => {
|
|
557
|
+
if (!hostedRunner) {
|
|
558
|
+
throw new Error('The Stratus runtime is not ready to execute tools yet.');
|
|
559
|
+
}
|
|
560
|
+
return hostedRunner.executeHostedToolCall(session, call, context);
|
|
561
|
+
}, options.maxTurns);
|
|
1031
562
|
const runner = new AgentRunner({
|
|
1032
563
|
provider: runtimeProvider,
|
|
1033
564
|
tools,
|
|
1034
565
|
executor: createLocalCommandExecutor(),
|
|
1035
|
-
approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
|
|
566
|
+
approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
|
|
1036
567
|
bus,
|
|
1037
568
|
memory,
|
|
1038
569
|
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
1039
570
|
});
|
|
571
|
+
hostedRunner = runner;
|
|
1040
572
|
await runner.initialize();
|
|
1041
573
|
// A soul is a full identity — without one, every provider serves the
|
|
1042
574
|
// same built-in Stratus persona.
|
|
1043
575
|
const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
|
|
1044
576
|
const metadata = options.runtime.provider === 'demo'
|
|
1045
|
-
? { provider: 'demo' }
|
|
577
|
+
? { provider: 'demo', executor: 'local-command' }
|
|
1046
578
|
: {
|
|
1047
579
|
provider: options.runtime.provider,
|
|
1048
580
|
model: options.runtime.model,
|
|
1049
581
|
...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
|
|
1050
582
|
};
|
|
583
|
+
return { runner, agent, metadata };
|
|
584
|
+
};
|
|
585
|
+
export const runSingleLoop = async (prompt, streams, options) => {
|
|
586
|
+
const { runner, agent, metadata } = await createAgentRuntime(streams, options);
|
|
1051
587
|
return runner.run({
|
|
1052
588
|
sessionId: randomUUID(),
|
|
1053
589
|
agent,
|
|
1054
590
|
userMessage: prompt,
|
|
1055
|
-
metadata
|
|
1056
|
-
? {
|
|
1057
|
-
...metadata,
|
|
1058
|
-
executor: 'local-command',
|
|
1059
|
-
}
|
|
1060
|
-
: metadata,
|
|
591
|
+
metadata,
|
|
1061
592
|
});
|
|
1062
593
|
};
|
|
1063
594
|
export const printSessionSummary = (session, streams) => {
|
|
@@ -1077,6 +608,219 @@ export const printSessionSummary = (session, streams) => {
|
|
|
1077
608
|
writeLine(streams.stdout, `[${message.role}${nameSuffix}] ${content}`);
|
|
1078
609
|
}
|
|
1079
610
|
};
|
|
611
|
+
const lastAssistantReply = (session) => {
|
|
612
|
+
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
|
|
613
|
+
const message = session.messages[index];
|
|
614
|
+
if (message && message.role === 'assistant' && message.content.trim().length > 0) {
|
|
615
|
+
return message.content;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return '(no reply)';
|
|
619
|
+
};
|
|
620
|
+
const CHAT_HELP = [
|
|
621
|
+
' /help show this',
|
|
622
|
+
' /exit leave the chat (Ctrl+C and Ctrl+D work too)',
|
|
623
|
+
'Everything else is a message to your agent. The conversation persists',
|
|
624
|
+
'across turns, and facts they remember stick forever.',
|
|
625
|
+
].join('\n');
|
|
626
|
+
export const runChat = async (command, streams, env = {}) => {
|
|
627
|
+
const runtime = await resolveRuntimeConfig({
|
|
628
|
+
command: 'run',
|
|
629
|
+
prompt: '',
|
|
630
|
+
format: 'text',
|
|
631
|
+
events: false,
|
|
632
|
+
approvals: command.approvals,
|
|
633
|
+
...(command.provider ? { provider: command.provider } : {}),
|
|
634
|
+
...(command.model ? { model: command.model } : {}),
|
|
635
|
+
...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
|
|
636
|
+
...(command.soul ? { soul: command.soul } : {}),
|
|
637
|
+
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
638
|
+
}, env);
|
|
639
|
+
// Interactive means the real terminal: an injected stdinStream is by
|
|
640
|
+
// definition not a TTY conversation, so it never gets prompts, ANSI
|
|
641
|
+
// styling, or a terminal readline wired to a stream that can't take it.
|
|
642
|
+
const interactive = env.setupInput === undefined
|
|
643
|
+
&& env.stdinStream === undefined
|
|
644
|
+
&& process.stdin.isTTY === true;
|
|
645
|
+
// Styling is for eyes: piped transcripts stay plain text.
|
|
646
|
+
const bold = (text) => (interactive ? `\u001b[1m${text}\u001b[0m` : text);
|
|
647
|
+
const dim = (text) => (interactive ? `\u001b[2m${text}\u001b[0m` : text);
|
|
648
|
+
const input = env.stdinStream ?? process.stdin;
|
|
649
|
+
const rl = interactive
|
|
650
|
+
? createInterface({
|
|
651
|
+
input,
|
|
652
|
+
output: streams.stdout,
|
|
653
|
+
prompt: '\u001b[36myou ›\u001b[0m ',
|
|
654
|
+
terminal: true,
|
|
655
|
+
})
|
|
656
|
+
: createInterface({ input, terminal: false });
|
|
657
|
+
// One reader owns stdin. Every line lands in this queue, and whoever is
|
|
658
|
+
// waiting — the chat loop, or an approval question mid-turn — takes the
|
|
659
|
+
// next one. A second readline would race for the same bytes, and
|
|
660
|
+
// rl.question cannot be used while the interface is being iterated.
|
|
661
|
+
const lineQueue = [];
|
|
662
|
+
let inputClosed = false;
|
|
663
|
+
let pendingApproval;
|
|
664
|
+
let notifyLine;
|
|
665
|
+
rl.on('line', (rawLine) => {
|
|
666
|
+
if (pendingApproval) {
|
|
667
|
+
const resolve = pendingApproval;
|
|
668
|
+
pendingApproval = undefined;
|
|
669
|
+
resolve(rawLine);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
lineQueue.push(rawLine);
|
|
673
|
+
notifyLine?.();
|
|
674
|
+
});
|
|
675
|
+
rl.on('close', () => {
|
|
676
|
+
inputClosed = true;
|
|
677
|
+
if (pendingApproval) {
|
|
678
|
+
const resolve = pendingApproval;
|
|
679
|
+
pendingApproval = undefined;
|
|
680
|
+
resolve('');
|
|
681
|
+
}
|
|
682
|
+
notifyLine?.();
|
|
683
|
+
});
|
|
684
|
+
const nextLine = () => {
|
|
685
|
+
if (lineQueue.length > 0) {
|
|
686
|
+
return Promise.resolve(lineQueue.shift());
|
|
687
|
+
}
|
|
688
|
+
if (inputClosed) {
|
|
689
|
+
return Promise.resolve(undefined);
|
|
690
|
+
}
|
|
691
|
+
return new Promise((resolve) => {
|
|
692
|
+
notifyLine = () => {
|
|
693
|
+
notifyLine = undefined;
|
|
694
|
+
resolve(lineQueue.shift());
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
};
|
|
698
|
+
// An approval consumes the next unconsumed line (typed live, or already
|
|
699
|
+
// queued from piped input). A closed stream denies instead of hanging.
|
|
700
|
+
const askApproval = (prompt) => {
|
|
701
|
+
streams.stderr.write(prompt);
|
|
702
|
+
const queued = lineQueue.shift();
|
|
703
|
+
if (queued !== undefined) {
|
|
704
|
+
return Promise.resolve(queued);
|
|
705
|
+
}
|
|
706
|
+
if (inputClosed) {
|
|
707
|
+
return Promise.resolve('');
|
|
708
|
+
}
|
|
709
|
+
return new Promise((resolve) => {
|
|
710
|
+
pendingApproval = resolve;
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
const { runner, agent, metadata } = await createAgentRuntime(streams, {
|
|
714
|
+
runtime,
|
|
715
|
+
approvals: command.approvals,
|
|
716
|
+
askApproval,
|
|
717
|
+
...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
|
|
718
|
+
env,
|
|
719
|
+
onEvent: (event) => {
|
|
720
|
+
if (command.events) {
|
|
721
|
+
const line = formatEvent(event);
|
|
722
|
+
if (line) {
|
|
723
|
+
writeLine(streams.stdout, dim(line));
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// Quiet by default — just a whisper when the agent reaches for a tool.
|
|
728
|
+
if (event.type === 'tool.called') {
|
|
729
|
+
writeLine(streams.stdout, dim(` · using ${event.call.toolName}`));
|
|
730
|
+
}
|
|
731
|
+
else if (event.type === 'tool.denied') {
|
|
732
|
+
writeLine(streams.stdout, dim(` · ${event.call.toolName} denied`));
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
const modelLine = runtime.provider === 'demo'
|
|
737
|
+
? 'demo (offline)'
|
|
738
|
+
: `${runtime.provider} · ${runtime.model}`;
|
|
739
|
+
if (interactive) {
|
|
740
|
+
streams.stdout.write('\u001b[2J\u001b[H');
|
|
741
|
+
for (const line of stratusHeaderLines()) {
|
|
742
|
+
writeLine(streams.stdout, line);
|
|
743
|
+
}
|
|
744
|
+
writeLine(streams.stdout);
|
|
745
|
+
writeLine(streams.stdout, `Chatting with ${bold(agent.name)} — ${modelLine}.`);
|
|
746
|
+
writeLine(streams.stdout, dim('The conversation persists across turns. /exit to leave, /help for more.'));
|
|
747
|
+
writeLine(streams.stdout);
|
|
748
|
+
}
|
|
749
|
+
// Ctrl+C between turns closes the chat gracefully; mid-turn there is
|
|
750
|
+
// nothing to cancel in the kernel yet, so leave immediately instead of
|
|
751
|
+
// silently waiting out a slow provider call.
|
|
752
|
+
let turnInFlight = false;
|
|
753
|
+
rl.on('SIGINT', () => {
|
|
754
|
+
if (turnInFlight) {
|
|
755
|
+
writeLine(streams.stdout);
|
|
756
|
+
writeLine(streams.stdout, dim('Interrupted.'));
|
|
757
|
+
process.exit(130);
|
|
758
|
+
}
|
|
759
|
+
rl.close();
|
|
760
|
+
});
|
|
761
|
+
// One session for the whole sitting: the first message starts it, every
|
|
762
|
+
// later message resumes it — the same plumbing a channel will use to
|
|
763
|
+
// keep a thread alive.
|
|
764
|
+
let sessionId;
|
|
765
|
+
if (interactive) {
|
|
766
|
+
rl.prompt();
|
|
767
|
+
}
|
|
768
|
+
for (;;) {
|
|
769
|
+
const rawLine = await nextLine();
|
|
770
|
+
if (rawLine === undefined) {
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
const line = rawLine.trim();
|
|
774
|
+
if (line.length === 0) {
|
|
775
|
+
if (interactive) {
|
|
776
|
+
rl.prompt();
|
|
777
|
+
}
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (line === '/exit' || line === '/quit' || line === 'exit' || line === 'quit') {
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
if (line === '/help') {
|
|
784
|
+
writeLine(streams.stdout, CHAT_HELP);
|
|
785
|
+
if (interactive) {
|
|
786
|
+
rl.prompt();
|
|
787
|
+
}
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (!interactive) {
|
|
791
|
+
writeLine(streams.stdout, `you › ${line}`);
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
turnInFlight = true;
|
|
795
|
+
let session;
|
|
796
|
+
if (sessionId === undefined) {
|
|
797
|
+
const id = randomUUID();
|
|
798
|
+
sessionId = id;
|
|
799
|
+
session = await runner.run({ sessionId: id, agent, userMessage: line, metadata });
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
session = await runner.resume({ sessionId, userMessage: line });
|
|
803
|
+
}
|
|
804
|
+
writeLine(streams.stdout, `${bold(`${agent.name} ›`)} ${lastAssistantReply(session)}`);
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
writeLine(streams.stderr, `Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
808
|
+
writeLine(streams.stdout, dim('(that turn failed — the conversation is still here, try again)'));
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
turnInFlight = false;
|
|
812
|
+
}
|
|
813
|
+
writeLine(streams.stdout);
|
|
814
|
+
if (interactive) {
|
|
815
|
+
rl.prompt();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
rl.close();
|
|
819
|
+
if (interactive) {
|
|
820
|
+
writeLine(streams.stdout, dim(`Bye — ${agent.name} keeps what they remembered.`));
|
|
821
|
+
}
|
|
822
|
+
return 0;
|
|
823
|
+
};
|
|
1080
824
|
const formatRuntimeBanner = (runtime) => {
|
|
1081
825
|
const soulSuffix = runtime.soul ? ` as ${runtime.soul.agent.name}` : '';
|
|
1082
826
|
if (runtime.provider === 'demo') {
|
|
@@ -2493,6 +2237,150 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2493
2237
|
prompter.close();
|
|
2494
2238
|
}
|
|
2495
2239
|
};
|
|
2240
|
+
const personaSnippet = (instructions) => {
|
|
2241
|
+
const firstLine = instructions?.split('\n').map((line) => line.trim()).find((line) => line.length > 0);
|
|
2242
|
+
if (!firstLine) {
|
|
2243
|
+
return undefined;
|
|
2244
|
+
}
|
|
2245
|
+
return firstLine.length > 78 ? `${firstLine.slice(0, 77)}…` : firstLine;
|
|
2246
|
+
};
|
|
2247
|
+
export const runAgents = async (command, streams, env = {}) => {
|
|
2248
|
+
// Fold any legacy per-directory memory in first so the counts below
|
|
2249
|
+
// reflect everything each agent actually remembers.
|
|
2250
|
+
await migrateLegacyMemory(env);
|
|
2251
|
+
const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
|
|
2252
|
+
const processEnv = readProcessEnv(env);
|
|
2253
|
+
// Listing must never be blocked by a broken config — it only feeds the
|
|
2254
|
+
// default marker and the "runs on" lines.
|
|
2255
|
+
const { config: activeConfig } = await discoverActiveConfig(env, (message) => {
|
|
2256
|
+
writeLine(streams.stderr, `Warning: ${message}.`);
|
|
2257
|
+
});
|
|
2258
|
+
const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
2259
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'));
|
|
2260
|
+
const envModel = readNonEmptyString(processEnv.STRATUS_MODEL)
|
|
2261
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL);
|
|
2262
|
+
// What a run as this soul would actually use right now — the same
|
|
2263
|
+
// precedence as resolveRuntimeConfig (env vars, soul hints, config,
|
|
2264
|
+
// demo), so a model-only or provider-only pin still resolves honestly.
|
|
2265
|
+
const runsOnFor = (soulProvider, soulModel) => {
|
|
2266
|
+
const provider = envProvider ?? soulProvider ?? activeConfig.provider ?? 'demo';
|
|
2267
|
+
if (provider === 'demo') {
|
|
2268
|
+
return { provider };
|
|
2269
|
+
}
|
|
2270
|
+
const soulModelApplies = soulProvider === undefined || soulProvider === provider;
|
|
2271
|
+
const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
|
|
2272
|
+
const model = envModel
|
|
2273
|
+
?? (soulModelApplies ? soulModel : undefined)
|
|
2274
|
+
?? (configModelApplies ? activeConfig.model : undefined)
|
|
2275
|
+
?? (provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
|
|
2276
|
+
return { provider, model };
|
|
2277
|
+
};
|
|
2278
|
+
const defaultSoulPath = readNonEmptyString(processEnv.STRATUS_SOUL)
|
|
2279
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
|
|
2280
|
+
?? activeConfig.soul;
|
|
2281
|
+
const resolvedDefaultSoul = defaultSoulPath
|
|
2282
|
+
? path.resolve(readWorkingDirectory(env), defaultSoulPath)
|
|
2283
|
+
: undefined;
|
|
2284
|
+
const listings = [];
|
|
2285
|
+
const addSoul = async (soulPath) => {
|
|
2286
|
+
let parsed;
|
|
2287
|
+
try {
|
|
2288
|
+
parsed = parseSoul(await readFile(soulPath, 'utf8'), { seed: soulPath });
|
|
2289
|
+
}
|
|
2290
|
+
catch (error) {
|
|
2291
|
+
writeLine(streams.stderr, `Warning: skipping ${soulPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
const { agent } = parsed;
|
|
2295
|
+
const persona = personaSnippet(agent.instructions);
|
|
2296
|
+
listings.push({
|
|
2297
|
+
id: agent.id,
|
|
2298
|
+
name: agent.name,
|
|
2299
|
+
isDefault: soulPath === resolvedDefaultSoul,
|
|
2300
|
+
builtIn: false,
|
|
2301
|
+
soulPath,
|
|
2302
|
+
...(parsed.provider ? { provider: parsed.provider } : {}),
|
|
2303
|
+
...(parsed.model ? { model: parsed.model } : {}),
|
|
2304
|
+
runsOn: runsOnFor(parsed.provider, parsed.model),
|
|
2305
|
+
memories: (await memory.list(agent.id)).length,
|
|
2306
|
+
...(persona ? { persona } : {}),
|
|
2307
|
+
...(agent.avatar ? { avatar: `${agent.avatar.style} theme, hue ${agent.avatar.hue}, palette ${agent.avatar.palette.join(' ')}` } : {}),
|
|
2308
|
+
});
|
|
2309
|
+
};
|
|
2310
|
+
let rosterFiles = [];
|
|
2311
|
+
try {
|
|
2312
|
+
rosterFiles = (await readdir(agentsDirPath(env)))
|
|
2313
|
+
.filter((file) => file.endsWith('.md'))
|
|
2314
|
+
.sort()
|
|
2315
|
+
.map((file) => path.join(agentsDirPath(env), file));
|
|
2316
|
+
}
|
|
2317
|
+
catch (error) {
|
|
2318
|
+
if (error.code !== 'ENOENT') {
|
|
2319
|
+
throw error;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
for (const soulPath of rosterFiles) {
|
|
2323
|
+
await addSoul(soulPath);
|
|
2324
|
+
}
|
|
2325
|
+
// A default soul can live outside ~/.stratus/agents (a project soul, a
|
|
2326
|
+
// hand-written file) — the roster would be lying without it.
|
|
2327
|
+
if (resolvedDefaultSoul && !rosterFiles.includes(resolvedDefaultSoul)) {
|
|
2328
|
+
await addSoul(resolvedDefaultSoul);
|
|
2329
|
+
}
|
|
2330
|
+
// The built-in Stratus persona serves every run that has no soul.
|
|
2331
|
+
const builtInPersona = personaSnippet(DEFAULT_STRATUS_AGENT.instructions);
|
|
2332
|
+
listings.push({
|
|
2333
|
+
id: DEFAULT_STRATUS_AGENT.id,
|
|
2334
|
+
name: DEFAULT_STRATUS_AGENT.name,
|
|
2335
|
+
isDefault: resolvedDefaultSoul === undefined,
|
|
2336
|
+
builtIn: true,
|
|
2337
|
+
runsOn: runsOnFor(),
|
|
2338
|
+
memories: (await memory.list(DEFAULT_STRATUS_AGENT.id)).length,
|
|
2339
|
+
...(builtInPersona ? { persona: builtInPersona } : {}),
|
|
2340
|
+
});
|
|
2341
|
+
listings.sort((a, b) => {
|
|
2342
|
+
if (a.isDefault !== b.isDefault) {
|
|
2343
|
+
return a.isDefault ? -1 : 1;
|
|
2344
|
+
}
|
|
2345
|
+
if (a.builtIn !== b.builtIn) {
|
|
2346
|
+
return a.builtIn ? 1 : -1;
|
|
2347
|
+
}
|
|
2348
|
+
return a.name.localeCompare(b.name);
|
|
2349
|
+
});
|
|
2350
|
+
if (command.format === 'json') {
|
|
2351
|
+
writeLine(streams.stdout, JSON.stringify({
|
|
2352
|
+
agents: listings.map(({ isDefault, ...rest }) => ({ ...rest, default: isDefault })),
|
|
2353
|
+
}, null, 2));
|
|
2354
|
+
return 0;
|
|
2355
|
+
}
|
|
2356
|
+
const describeRunsOn = (runsOn) => (runsOn.provider === 'demo' ? 'demo (offline)' : `${runsOn.provider}${runsOn.model ? ` · ${runsOn.model}` : ''}`);
|
|
2357
|
+
writeLine(streams.stdout, 'Agents');
|
|
2358
|
+
for (const agent of listings) {
|
|
2359
|
+
const labels = [
|
|
2360
|
+
...(agent.isDefault ? ['default'] : []),
|
|
2361
|
+
...(agent.builtIn ? ['built-in'] : []),
|
|
2362
|
+
];
|
|
2363
|
+
writeLine(streams.stdout);
|
|
2364
|
+
writeLine(streams.stdout, ` ${agent.name}${labels.length > 0 ? ` (${labels.join(', ')})` : ''}`);
|
|
2365
|
+
writeLine(streams.stdout, ` id ${agent.id}`);
|
|
2366
|
+
if (agent.soulPath) {
|
|
2367
|
+
writeLine(streams.stdout, ` soul ${agent.soulPath}`);
|
|
2368
|
+
}
|
|
2369
|
+
writeLine(streams.stdout, ` runs on ${agent.provider ? describeRunsOn(agent.runsOn) : `follows your setup — currently ${describeRunsOn(agent.runsOn)}`}`);
|
|
2370
|
+
writeLine(streams.stdout, ` memory ${agent.memories === 0 ? 'nothing yet' : `${agent.memories} remembered fact${agent.memories === 1 ? '' : 's'}`}`);
|
|
2371
|
+
if (agent.persona) {
|
|
2372
|
+
writeLine(streams.stdout, ` persona ${agent.persona}`);
|
|
2373
|
+
}
|
|
2374
|
+
if (agent.avatar) {
|
|
2375
|
+
writeLine(streams.stdout, ` avatar ${agent.avatar}`);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
writeLine(streams.stdout);
|
|
2379
|
+
writeLine(streams.stdout, listings.length === 1
|
|
2380
|
+
? 'That is just the built-in default — create your own with: stratus agent new'
|
|
2381
|
+
: 'Talk to the default with stratus run, or to anyone with stratus run --soul <file>.');
|
|
2382
|
+
return 0;
|
|
2383
|
+
};
|
|
2496
2384
|
export const runAgentNew = async (command, streams, env = {}) => {
|
|
2497
2385
|
// On a real terminal, creating an agent is the same guided experience as
|
|
2498
2386
|
// setup: a headed screen, a prefilled (editable) name, a personality, and
|
|
@@ -2524,18 +2412,11 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
2524
2412
|
// at all: the soul keeps following the machine's configuration
|
|
2525
2413
|
// instead of demanding credentials nothing has signed in for.
|
|
2526
2414
|
const processEnv = readProcessEnv(env);
|
|
2527
|
-
const configLocation = await resolveConfigLocation({}, env);
|
|
2528
2415
|
// Creating an agent must not be blocked by a broken config — it only
|
|
2529
2416
|
// feeds the soul's provider/model hint, so fall back to defaults.
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
activeConfig = await loadConfigFile(configLocation.path);
|
|
2534
|
-
}
|
|
2535
|
-
catch (error) {
|
|
2536
|
-
writeLine(streams.stdout, `Note: ignoring unreadable config ${configLocation.path} (${error instanceof Error ? error.message : String(error)}).`);
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2417
|
+
const { location: configLocation, config: activeConfig } = await discoverActiveConfig(env, (message) => {
|
|
2418
|
+
writeLine(streams.stdout, `Note: ${message}.`);
|
|
2419
|
+
});
|
|
2539
2420
|
const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
2540
2421
|
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
|
|
2541
2422
|
?? activeConfig.provider
|
|
@@ -2667,6 +2548,119 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
2667
2548
|
await handle.close();
|
|
2668
2549
|
return 0;
|
|
2669
2550
|
};
|
|
2551
|
+
/**
|
|
2552
|
+
* Loads the optional Slack channel package, or undefined when it is not
|
|
2553
|
+
* installed. Only that package being absent is tolerated: an adapter that
|
|
2554
|
+
* fails to load for any other reason (a broken install, a bad transitive
|
|
2555
|
+
* dependency) surfaces rather than silently disabling Slack for a daemon
|
|
2556
|
+
* whose stored tokens say it should be running.
|
|
2557
|
+
*/
|
|
2558
|
+
const loadSlackAdapter = async () => {
|
|
2559
|
+
// Resolution and loading are separate questions, and only the first one
|
|
2560
|
+
// means "not installed". Inspecting the import's error message cannot
|
|
2561
|
+
// tell them apart: a package that IS installed but is missing one of
|
|
2562
|
+
// its own dependencies throws ERR_MODULE_NOT_FOUND naming that
|
|
2563
|
+
// dependency and this package as the importer — a broken install would
|
|
2564
|
+
// read as an absent one, silently disabling a channel whose tokens say
|
|
2565
|
+
// it should be running.
|
|
2566
|
+
try {
|
|
2567
|
+
import.meta.resolve('@stratusagent/channel-slack');
|
|
2568
|
+
}
|
|
2569
|
+
catch {
|
|
2570
|
+
return undefined;
|
|
2571
|
+
}
|
|
2572
|
+
// Resolvable: any failure from here is a real problem with the
|
|
2573
|
+
// installed package, and surfaces.
|
|
2574
|
+
return (await import('@stratusagent/channel-slack')).createSlackChannelAdapter;
|
|
2575
|
+
};
|
|
2576
|
+
/**
|
|
2577
|
+
* `stratus serve` — run the gateway (stratusd) in the foreground: load the
|
|
2578
|
+
* roster, accept dispatches, print events, and drain cleanly on SIGTERM,
|
|
2579
|
+
* SIGINT, or the injected shutdown signal.
|
|
2580
|
+
*/
|
|
2581
|
+
export const runServe = async (command, streams, env = {}) => {
|
|
2582
|
+
// Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
|
|
2583
|
+
// command neither needs nor should pay for (Node still prints an
|
|
2584
|
+
// experimental warning for it).
|
|
2585
|
+
const { createGateway } = await import('@stratusagent/gateway');
|
|
2586
|
+
const log = (line) => writeLine(streams.stdout, line);
|
|
2587
|
+
const warn = (line) => writeLine(streams.stderr, `Warning: ${line}`);
|
|
2588
|
+
// Agents with stored Slack tokens go live in Slack automatically — the
|
|
2589
|
+
// tokens are gateway infrastructure secrets in the channels namespace of
|
|
2590
|
+
// ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
|
|
2591
|
+
// for the 2-minute per-agent app setup).
|
|
2592
|
+
const channelCredentials = await loadChannelCredentials(env);
|
|
2593
|
+
const slackAgents = Object.entries(channelCredentials.slack ?? {});
|
|
2594
|
+
const channels = [];
|
|
2595
|
+
if (slackAgents.length > 0) {
|
|
2596
|
+
// Channel packages are optional peers: the CLI never bundles a
|
|
2597
|
+
// transport nobody asked for (the Slack SDKs alone are ~9 MB). A
|
|
2598
|
+
// missing one degrades like a broken app does — the daemon serves
|
|
2599
|
+
// every other channel — with an actionable line instead of a
|
|
2600
|
+
// module-not-found stack.
|
|
2601
|
+
const adapter = await loadSlackAdapter();
|
|
2602
|
+
if (adapter) {
|
|
2603
|
+
channels.push(adapter({
|
|
2604
|
+
agents: slackAgents.map(([agentId, tokens]) => ({
|
|
2605
|
+
agentId,
|
|
2606
|
+
appToken: tokens.appToken,
|
|
2607
|
+
botToken: tokens.botToken,
|
|
2608
|
+
})),
|
|
2609
|
+
log,
|
|
2610
|
+
warn,
|
|
2611
|
+
}));
|
|
2612
|
+
}
|
|
2613
|
+
else {
|
|
2614
|
+
warn(`Slack tokens are stored for ${slackAgents.length} agent(s), but @stratusagent/channel-slack is not installed. `
|
|
2615
|
+
+ 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
const gateway = createGateway({
|
|
2619
|
+
env,
|
|
2620
|
+
...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
|
|
2621
|
+
...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
|
|
2622
|
+
...(channels.length > 0 ? { channels } : {}),
|
|
2623
|
+
log,
|
|
2624
|
+
warn,
|
|
2625
|
+
});
|
|
2626
|
+
if (command.events) {
|
|
2627
|
+
gateway.bus.subscribe((event) => {
|
|
2628
|
+
const line = formatEvent(event);
|
|
2629
|
+
if (line) {
|
|
2630
|
+
writeLine(streams.stdout, line);
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
await gateway.start();
|
|
2635
|
+
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
2636
|
+
// Hold the event loop open until a shutdown request, then drain: new
|
|
2637
|
+
// dispatches are refused while in-flight turns finish.
|
|
2638
|
+
await new Promise((resolve) => {
|
|
2639
|
+
const keepAlive = setInterval(() => { }, 2_147_000_000);
|
|
2640
|
+
let settled = false;
|
|
2641
|
+
const shutdown = () => {
|
|
2642
|
+
if (settled) {
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
settled = true;
|
|
2646
|
+
clearInterval(keepAlive);
|
|
2647
|
+
process.off('SIGTERM', shutdown);
|
|
2648
|
+
process.off('SIGINT', shutdown);
|
|
2649
|
+
resolve();
|
|
2650
|
+
};
|
|
2651
|
+
process.once('SIGTERM', shutdown);
|
|
2652
|
+
process.once('SIGINT', shutdown);
|
|
2653
|
+
if (env.shutdownSignal?.aborted) {
|
|
2654
|
+
shutdown();
|
|
2655
|
+
}
|
|
2656
|
+
else {
|
|
2657
|
+
env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
|
|
2658
|
+
}
|
|
2659
|
+
});
|
|
2660
|
+
writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
|
|
2661
|
+
await gateway.stop();
|
|
2662
|
+
return 0;
|
|
2663
|
+
};
|
|
2670
2664
|
export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
2671
2665
|
try {
|
|
2672
2666
|
const resolvedEnv = argv.includes('--stdin') && env.stdin === undefined
|
|
@@ -2683,12 +2677,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
2683
2677
|
if (command.command === 'agent-new') {
|
|
2684
2678
|
return runAgentNew(command, streams, resolvedEnv);
|
|
2685
2679
|
}
|
|
2680
|
+
if (command.command === 'agents') {
|
|
2681
|
+
return runAgents(command, streams, resolvedEnv);
|
|
2682
|
+
}
|
|
2683
|
+
if (command.command === 'chat') {
|
|
2684
|
+
return runChat(command, streams, resolvedEnv);
|
|
2685
|
+
}
|
|
2686
2686
|
if (command.command === 'setup') {
|
|
2687
2687
|
return runSetup(command, streams, resolvedEnv);
|
|
2688
2688
|
}
|
|
2689
2689
|
if (command.command === 'dashboard') {
|
|
2690
2690
|
return runDashboard(command, streams, resolvedEnv);
|
|
2691
2691
|
}
|
|
2692
|
+
if (command.command === 'serve') {
|
|
2693
|
+
return runServe(command, streams, resolvedEnv);
|
|
2694
|
+
}
|
|
2692
2695
|
const runtime = await resolveRuntimeConfig(command, resolvedEnv);
|
|
2693
2696
|
if (command.format === 'text') {
|
|
2694
2697
|
writeLine(streams.stdout, formatRuntimeBanner(runtime));
|