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