@stratusagent/cli 0.4.0 → 0.6.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 +293 -9
- package/dist/index.d.ts +37 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +548 -613
- package/dist/index.js.map +1 -1
- package/package.json +22 -12
package/dist/index.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { randomUUID } from 'node:crypto';
|
|
2
10
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
import { createServer } from 'node:http';
|
|
5
|
-
import { once } from 'node:events';
|
|
11
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
6
12
|
import path from 'node:path';
|
|
7
13
|
import { createInterface } from 'node:readline';
|
|
8
14
|
import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
|
|
9
15
|
import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
10
|
-
import {
|
|
16
|
+
import { loadPlugins } from '@stratusagent/plugins';
|
|
17
|
+
import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
|
|
11
18
|
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
12
|
-
import {
|
|
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,
|
|
19
|
+
import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
20
|
+
import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
|
|
14
21
|
import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
|
|
15
22
|
export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
|
|
16
23
|
import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
|
|
@@ -19,12 +26,7 @@ export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRec
|
|
|
19
26
|
// provider wiring now (the gateway uses the same code); the CLI re-exports
|
|
20
27
|
// its historical surface so existing importers keep working.
|
|
21
28
|
export { createFileMemoryStore } from '@stratusagent/state';
|
|
22
|
-
|
|
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.4.0';
|
|
27
|
-
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
29
|
+
export const CLI_VERSION = '0.6.0';
|
|
28
30
|
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
29
31
|
/**
|
|
30
32
|
* The range every package declares in its `engines` field. Two floors, not
|
|
@@ -77,6 +79,7 @@ Usage:
|
|
|
77
79
|
STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
|
|
78
80
|
stratus run --config ./stratus.config.json --provider openai "Say hello"
|
|
79
81
|
stratus agents
|
|
82
|
+
stratus agents --gateway http://127.0.0.1:4123
|
|
80
83
|
stratus doctor
|
|
81
84
|
stratus service install
|
|
82
85
|
stratus service status
|
|
@@ -100,7 +103,10 @@ Commands:
|
|
|
100
103
|
(--idle-timeout <seconds>, --approvals <headless|remote>,
|
|
101
104
|
--no-events, --no-log-file, --config <path>); everything it
|
|
102
105
|
says is also written to ~/.stratus/logs, which
|
|
103
|
-
"stratus logs" reads
|
|
106
|
+
"stratus logs" reads. With @stratusagent/control-api
|
|
107
|
+
installed it also serves the HTTP + WebSocket control API
|
|
108
|
+
on 127.0.0.1:4123 (--no-api, --api-host, --api-port, or
|
|
109
|
+
the config file's "api" block)
|
|
104
110
|
service Keep stratusd running under launchd (macOS) or systemd
|
|
105
111
|
(Linux): install, uninstall, status, start, stop.
|
|
106
112
|
Installing starts it now and at every login
|
|
@@ -111,11 +117,16 @@ Commands:
|
|
|
111
117
|
--format json for the raw records
|
|
112
118
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
113
119
|
agents List your agents: who they are, where their souls live, what
|
|
114
|
-
they run on, what they remember (also: stratus agent list)
|
|
120
|
+
they run on, what they remember (also: stratus agent list).
|
|
121
|
+
--gateway <url> asks a running daemon instead of resolving
|
|
122
|
+
locally, authenticating with ~/.stratus/gateway-token
|
|
123
|
+
(override with --token or STRATUS_GATEWAY_TOKEN)
|
|
115
124
|
doctor Show what a run would use right now — provider, model, soul —
|
|
116
125
|
and which file or environment variable decided each, then
|
|
117
126
|
flag anything that would surprise you (--format json)
|
|
118
|
-
dashboard
|
|
127
|
+
dashboard Open the web dashboard: finds a running daemon (or starts one),
|
|
128
|
+
mints a single-use sign-in link, and opens your browser at it.
|
|
129
|
+
Needs @stratusagent/control-api and @stratusagent/dashboard
|
|
119
130
|
help Show this help message
|
|
120
131
|
|
|
121
132
|
Agent options:
|
|
@@ -138,9 +149,14 @@ Options:
|
|
|
138
149
|
gated call) or remote (ask in Slack). Default headless, or
|
|
139
150
|
the config file's "approvals.mode"
|
|
140
151
|
--max-turns Maximum provider turns per run (default: 8)
|
|
141
|
-
--port
|
|
142
|
-
--host
|
|
152
|
+
--port dashboard: port for a daemon it starts (default: 4123)
|
|
153
|
+
--host dashboard: host for a daemon it starts (default: 127.0.0.1)
|
|
143
154
|
--no-open Do not open the browser automatically
|
|
155
|
+
--gateway agents: read the roster from a running daemon's control API
|
|
156
|
+
--token Bearer token for --gateway (default: ~/.stratus/gateway-token)
|
|
157
|
+
--no-api serve: do not serve the control API
|
|
158
|
+
--api-host serve: control API interface (default: 127.0.0.1)
|
|
159
|
+
--api-port serve: control API port (default: 4123, 0 for any free port)
|
|
144
160
|
--help, -h Show this help message
|
|
145
161
|
|
|
146
162
|
Config file:
|
|
@@ -149,9 +165,28 @@ Config file:
|
|
|
149
165
|
A "soul" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.
|
|
150
166
|
Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.
|
|
151
167
|
|
|
168
|
+
Plugins (tools):
|
|
169
|
+
Capability is optional: install a package, then list it under "plugins" in a
|
|
170
|
+
TRUSTED config (--config, STRATUS_CONFIG, or ~/.stratus/config.json), keyed by
|
|
171
|
+
package name. A plugin runs inside this process, so an auto-discovered
|
|
172
|
+
project-local stratus.config.json may not enable one.
|
|
173
|
+
|
|
174
|
+
"plugins": {
|
|
175
|
+
"@stratusagent/tool-fs": { "enabled": true, "roots": ["~/notes"],
|
|
176
|
+
"agents": { "ava": { "roots": ["~/work/ava"] } } },
|
|
177
|
+
"@stratusagent/tool-web": { "enabled": true }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
Available: @stratusagent/tool-fs (fs.read/list/search/write),
|
|
181
|
+
@stratusagent/tool-shell (shell.run), @stratusagent/tool-web (web.fetch),
|
|
182
|
+
@stratusagent/tool-browser (browser.goto/read/screenshot/act).
|
|
183
|
+
Installing one grants no agent anything — each soul lists what it may call.
|
|
184
|
+
|
|
152
185
|
Soul files:
|
|
153
186
|
A soul file is markdown with frontmatter (name, provider, model, tools, credentials)
|
|
154
187
|
followed by the agent's persona in prose. See examples/souls/ava.md.
|
|
188
|
+
"tools" takes exact names or a whole toolset: tools: [fs.read, fs.search] or
|
|
189
|
+
tools: [fs.*]. Omitted means every registered tool.
|
|
155
190
|
`;
|
|
156
191
|
const writeLine = (stream, line = '') => {
|
|
157
192
|
stream.write(`${line}\n`);
|
|
@@ -185,7 +220,7 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
185
220
|
}
|
|
186
221
|
if (command === 'dashboard') {
|
|
187
222
|
let port;
|
|
188
|
-
let host
|
|
223
|
+
let host;
|
|
189
224
|
let openBrowser = true;
|
|
190
225
|
for (let index = 0; index < rest.length; index += 1) {
|
|
191
226
|
const token = rest[index];
|
|
@@ -215,7 +250,12 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
215
250
|
}
|
|
216
251
|
throw new Error(`Unknown option: ${token}`);
|
|
217
252
|
}
|
|
218
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
command: 'dashboard',
|
|
255
|
+
...(port !== undefined ? { port } : {}),
|
|
256
|
+
...(host !== undefined ? { host } : {}),
|
|
257
|
+
openBrowser,
|
|
258
|
+
};
|
|
219
259
|
}
|
|
220
260
|
if (command === 'serve') {
|
|
221
261
|
const parsed = { command: 'serve', events: true };
|
|
@@ -258,6 +298,24 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
258
298
|
parsed.logToFile = false;
|
|
259
299
|
continue;
|
|
260
300
|
}
|
|
301
|
+
if (token === '--no-api') {
|
|
302
|
+
parsed.api = false;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (token === '--api-port') {
|
|
306
|
+
const port = Number(readOptionValue(rest, index, '--api-port'));
|
|
307
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
308
|
+
throw new Error(`Invalid value for --api-port: ${rest[index + 1] ?? '(missing)'}`);
|
|
309
|
+
}
|
|
310
|
+
parsed.apiPort = port;
|
|
311
|
+
index += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (token === '--api-host') {
|
|
315
|
+
parsed.apiHost = readOptionValue(rest, index, '--api-host');
|
|
316
|
+
index += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
261
319
|
throw new Error(`Unknown option: ${token}`);
|
|
262
320
|
}
|
|
263
321
|
return parsed;
|
|
@@ -326,15 +384,17 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
326
384
|
if (command === 'agents' || (command === 'agent' && rest[0] === 'list')) {
|
|
327
385
|
const agentsRest = command === 'agents' ? rest : rest.slice(1);
|
|
328
386
|
let format = 'text';
|
|
387
|
+
let gateway;
|
|
388
|
+
let token;
|
|
329
389
|
for (let index = 0; index < agentsRest.length; index += 1) {
|
|
330
|
-
const
|
|
331
|
-
if (!
|
|
390
|
+
const argument = agentsRest[index];
|
|
391
|
+
if (!argument) {
|
|
332
392
|
continue;
|
|
333
393
|
}
|
|
334
|
-
if (
|
|
394
|
+
if (argument === '--help' || argument === '-h') {
|
|
335
395
|
return { command: 'help' };
|
|
336
396
|
}
|
|
337
|
-
if (
|
|
397
|
+
if (argument === '--format') {
|
|
338
398
|
const value = readOptionValue(agentsRest, index, '--format');
|
|
339
399
|
if (value !== 'text' && value !== 'json') {
|
|
340
400
|
throw new Error(`Unsupported format: ${value}`);
|
|
@@ -343,9 +403,27 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
343
403
|
index += 1;
|
|
344
404
|
continue;
|
|
345
405
|
}
|
|
346
|
-
|
|
406
|
+
if (argument === '--gateway') {
|
|
407
|
+
gateway = readOptionValue(agentsRest, index, '--gateway');
|
|
408
|
+
index += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (argument === '--token') {
|
|
412
|
+
token = readOptionValue(agentsRest, index, '--token');
|
|
413
|
+
index += 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
417
|
+
}
|
|
418
|
+
if (token !== undefined && gateway === undefined) {
|
|
419
|
+
throw new Error('--token only applies with --gateway.');
|
|
347
420
|
}
|
|
348
|
-
return {
|
|
421
|
+
return {
|
|
422
|
+
command: 'agents',
|
|
423
|
+
format,
|
|
424
|
+
...(gateway ? { gateway } : {}),
|
|
425
|
+
...(token ? { token } : {}),
|
|
426
|
+
};
|
|
349
427
|
}
|
|
350
428
|
if (command === 'doctor') {
|
|
351
429
|
let format = 'text';
|
|
@@ -838,6 +916,30 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
838
916
|
}
|
|
839
917
|
});
|
|
840
918
|
}
|
|
919
|
+
// The same plugins the daemon would load, from the same trusted config,
|
|
920
|
+
// so a tool that works in `stratus run` works in `stratus serve` and a
|
|
921
|
+
// tool that is missing is missing in both. A local test that silently ran
|
|
922
|
+
// a different toolset than the daemon would be worse than no local test.
|
|
923
|
+
const pluginsConfig = await loadServePlugins(runEnv, options.configPath, (line) => {
|
|
924
|
+
writeLine(streams.stderr, `Warning: ${line}`);
|
|
925
|
+
});
|
|
926
|
+
const loadedPlugins = [];
|
|
927
|
+
if (Object.keys(pluginsConfig).length > 0) {
|
|
928
|
+
const result = await loadPlugins({
|
|
929
|
+
config: pluginsConfig,
|
|
930
|
+
host: {
|
|
931
|
+
resolve: (specifier) => import.meta.resolve(specifier),
|
|
932
|
+
import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
|
|
933
|
+
},
|
|
934
|
+
tools,
|
|
935
|
+
bus,
|
|
936
|
+
workspaceRoot: workspacesDirPath(runEnv),
|
|
937
|
+
});
|
|
938
|
+
loadedPlugins.push(...result.loaded);
|
|
939
|
+
for (const failure of result.failures) {
|
|
940
|
+
writeLine(streams.stderr, `Warning: plugin ${failure.package} did not load: ${failure.reason}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
841
943
|
// The Claude Code runtime executes kernel tools by calling back into the
|
|
842
944
|
// runner built just below — late-bound because the runner needs the
|
|
843
945
|
// provider first.
|
|
@@ -872,16 +974,36 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
872
974
|
model: options.runtime.model,
|
|
873
975
|
...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
|
|
874
976
|
};
|
|
875
|
-
|
|
977
|
+
// Handed back so a command can release what a plugin acquired — a browser
|
|
978
|
+
// above all. A one-shot `stratus run` that left a Chromium behind would
|
|
979
|
+
// be a leak per invocation.
|
|
980
|
+
const disposePlugins = async () => {
|
|
981
|
+
for (const plugin of loadedPlugins) {
|
|
982
|
+
try {
|
|
983
|
+
await plugin.instance.dispose?.();
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
writeLine(streams.stderr, `Warning: plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
return { runner, agent, metadata, disposePlugins };
|
|
876
991
|
};
|
|
877
992
|
export const runSingleLoop = async (prompt, streams, options) => {
|
|
878
|
-
const { runner, agent, metadata } = await createAgentRuntime(streams, options);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
993
|
+
const { runner, agent, metadata, disposePlugins } = await createAgentRuntime(streams, options);
|
|
994
|
+
try {
|
|
995
|
+
return await runner.run({
|
|
996
|
+
sessionId: randomUUID(),
|
|
997
|
+
agent,
|
|
998
|
+
userMessage: prompt,
|
|
999
|
+
metadata,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
finally {
|
|
1003
|
+
// Even when the run failed: a plugin that started a browser started it
|
|
1004
|
+
// before the turn could fail.
|
|
1005
|
+
await disposePlugins();
|
|
1006
|
+
}
|
|
885
1007
|
};
|
|
886
1008
|
export const printSessionSummary = (session, streams) => {
|
|
887
1009
|
writeLine(streams.stdout);
|
|
@@ -1003,11 +1125,12 @@ export const runChat = async (command, streams, env = {}) => {
|
|
|
1003
1125
|
pendingApproval = resolve;
|
|
1004
1126
|
});
|
|
1005
1127
|
};
|
|
1006
|
-
const { runner, agent, metadata } = await createAgentRuntime(streams, {
|
|
1128
|
+
const { runner, agent, metadata, disposePlugins } = await createAgentRuntime(streams, {
|
|
1007
1129
|
runtime,
|
|
1008
1130
|
approvals: command.approvals,
|
|
1009
1131
|
askApproval,
|
|
1010
1132
|
...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
|
|
1133
|
+
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
1011
1134
|
env,
|
|
1012
1135
|
onEvent: (event) => {
|
|
1013
1136
|
if (command.events) {
|
|
@@ -1109,6 +1232,8 @@ export const runChat = async (command, streams, env = {}) => {
|
|
|
1109
1232
|
}
|
|
1110
1233
|
}
|
|
1111
1234
|
rl.close();
|
|
1235
|
+
// A chat that held a browser open for an hour still has to put it down.
|
|
1236
|
+
await disposePlugins();
|
|
1112
1237
|
if (interactive) {
|
|
1113
1238
|
writeLine(streams.stdout, dim(`Bye — ${agent.name} keeps what they remembered.`));
|
|
1114
1239
|
}
|
|
@@ -1122,118 +1247,6 @@ const formatRuntimeBanner = (runtime) => {
|
|
|
1122
1247
|
const fallbackSuffix = runtime.fallback ? ` fallback=${runtime.fallback.model}` : '';
|
|
1123
1248
|
return `Starting Stratus Agent local loop with provider=${runtime.provider} model=${runtime.model}${fallbackSuffix}${soulSuffix}`;
|
|
1124
1249
|
};
|
|
1125
|
-
const escapeHtml = (value) => value
|
|
1126
|
-
.replaceAll('&', '&')
|
|
1127
|
-
.replaceAll('<', '<')
|
|
1128
|
-
.replaceAll('>', '>')
|
|
1129
|
-
.replaceAll('"', '"');
|
|
1130
|
-
const renderDashboardHtml = (url) => `<!doctype html>
|
|
1131
|
-
<html lang="en">
|
|
1132
|
-
<head>
|
|
1133
|
-
<meta charset="utf-8" />
|
|
1134
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1135
|
-
<title>${DASHBOARD_TITLE}</title>
|
|
1136
|
-
<style>
|
|
1137
|
-
:root { color-scheme: dark; }
|
|
1138
|
-
* { box-sizing: border-box; }
|
|
1139
|
-
body {
|
|
1140
|
-
margin: 0;
|
|
1141
|
-
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
1142
|
-
background: radial-gradient(circle at top, #1f3a5f 0%, #0b1020 45%, #05070f 100%);
|
|
1143
|
-
color: #f3f7ff;
|
|
1144
|
-
}
|
|
1145
|
-
main { max-width: 960px; margin: 0 auto; padding: 40px 20px 72px; }
|
|
1146
|
-
.hero, .card { background: rgba(10, 16, 32, 0.74); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 20px; backdrop-filter: blur(12px); }
|
|
1147
|
-
.hero { padding: 32px; margin-bottom: 20px; }
|
|
1148
|
-
.eyebrow { display: inline-block; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: #93c5fd; }
|
|
1149
|
-
h1 { font-size: clamp(32px, 6vw, 56px); line-height: 1; margin: 14px 0 12px; }
|
|
1150
|
-
p { color: #cbd5e1; line-height: 1.6; }
|
|
1151
|
-
.grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
|
1152
|
-
.card { padding: 24px; }
|
|
1153
|
-
.stat { font-size: 28px; font-weight: 700; margin: 6px 0; }
|
|
1154
|
-
code, pre, textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
1155
|
-
textarea {
|
|
1156
|
-
width: 100%; min-height: 120px; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.28);
|
|
1157
|
-
background: rgba(15, 23, 42, 0.9); color: #e2e8f0; padding: 14px; resize: vertical;
|
|
1158
|
-
}
|
|
1159
|
-
button {
|
|
1160
|
-
margin-top: 12px; border: 0; border-radius: 999px; padding: 12px 18px; font-weight: 700; cursor: pointer;
|
|
1161
|
-
background: linear-gradient(135deg, #60a5fa, #22d3ee); color: #08111f;
|
|
1162
|
-
}
|
|
1163
|
-
pre {
|
|
1164
|
-
margin: 14px 0 0; padding: 14px; border-radius: 14px; overflow: auto;
|
|
1165
|
-
background: rgba(2, 6, 23, 0.9); border: 1px solid rgba(148, 163, 184, 0.2); color: #bfdbfe;
|
|
1166
|
-
}
|
|
1167
|
-
a { color: #7dd3fc; }
|
|
1168
|
-
</style>
|
|
1169
|
-
</head>
|
|
1170
|
-
<body>
|
|
1171
|
-
<main>
|
|
1172
|
-
<section class="hero">
|
|
1173
|
-
<span class="eyebrow">Stratus Agent local dashboard</span>
|
|
1174
|
-
<h1>Stratus Agent is up.</h1>
|
|
1175
|
-
<p>A tiny dashboard for local testing. It confirms the CLI is reachable, gives you a quick action, and keeps the current repo intent visible.</p>
|
|
1176
|
-
<p><strong>Local URL:</strong> <a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>
|
|
1177
|
-
</section>
|
|
1178
|
-
<section class="grid">
|
|
1179
|
-
<article class="card">
|
|
1180
|
-
<div class="eyebrow">Status</div>
|
|
1181
|
-
<div class="stat">Ready</div>
|
|
1182
|
-
<p>The local server is running with Node standard library primitives only.</p>
|
|
1183
|
-
</article>
|
|
1184
|
-
<article class="card">
|
|
1185
|
-
<div class="eyebrow">About</div>
|
|
1186
|
-
<div class="stat">Minimal by design</div>
|
|
1187
|
-
<p>Use <code>stratus run</code> for the agent loop, or use the tester here to verify browser-to-local requests during development.</p>
|
|
1188
|
-
</article>
|
|
1189
|
-
</section>
|
|
1190
|
-
<section class="card" style="margin-top:20px;">
|
|
1191
|
-
<div class="eyebrow">Actionable test</div>
|
|
1192
|
-
<h2 style="margin:12px 0 8px;">Echo tester</h2>
|
|
1193
|
-
<p>Send a payload to the local dashboard API and inspect the response.</p>
|
|
1194
|
-
<textarea id="payload">Hello from Stratus Agent dashboard</textarea>
|
|
1195
|
-
<button id="send">POST /api/echo</button>
|
|
1196
|
-
<pre id="result">Waiting for input…</pre>
|
|
1197
|
-
</section>
|
|
1198
|
-
</main>
|
|
1199
|
-
<script>
|
|
1200
|
-
const button = document.getElementById('send');
|
|
1201
|
-
const payload = document.getElementById('payload');
|
|
1202
|
-
const result = document.getElementById('result');
|
|
1203
|
-
button.addEventListener('click', async () => {
|
|
1204
|
-
result.textContent = 'Sending...';
|
|
1205
|
-
try {
|
|
1206
|
-
const response = await fetch('/api/echo', {
|
|
1207
|
-
method: 'POST',
|
|
1208
|
-
headers: { 'content-type': 'application/json' },
|
|
1209
|
-
body: JSON.stringify({ text: payload.value })
|
|
1210
|
-
});
|
|
1211
|
-
const data = await response.json();
|
|
1212
|
-
result.textContent = JSON.stringify(data, null, 2);
|
|
1213
|
-
} catch (error) {
|
|
1214
|
-
result.textContent = String(error);
|
|
1215
|
-
}
|
|
1216
|
-
});
|
|
1217
|
-
</script>
|
|
1218
|
-
</body>
|
|
1219
|
-
</html>`;
|
|
1220
|
-
const readBody = async (request) => {
|
|
1221
|
-
let data = '';
|
|
1222
|
-
for await (const chunk of request) {
|
|
1223
|
-
data += chunk.toString();
|
|
1224
|
-
}
|
|
1225
|
-
return data;
|
|
1226
|
-
};
|
|
1227
|
-
const sendJson = (response, statusCode, payload) => {
|
|
1228
|
-
response.statusCode = statusCode;
|
|
1229
|
-
response.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1230
|
-
response.end(JSON.stringify(payload, null, 2));
|
|
1231
|
-
};
|
|
1232
|
-
const sendHtml = (response, html) => {
|
|
1233
|
-
response.statusCode = 200;
|
|
1234
|
-
response.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1235
|
-
response.end(html);
|
|
1236
|
-
};
|
|
1237
1250
|
export const openExternalUrl = async (url) => {
|
|
1238
1251
|
const platform = process.platform;
|
|
1239
1252
|
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
@@ -1247,74 +1260,6 @@ export const openExternalUrl = async (url) => {
|
|
|
1247
1260
|
});
|
|
1248
1261
|
});
|
|
1249
1262
|
};
|
|
1250
|
-
export const startDashboardServer = async (options) => {
|
|
1251
|
-
const server = createServer(async (request, response) => {
|
|
1252
|
-
const method = request.method ?? 'GET';
|
|
1253
|
-
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
|
1254
|
-
if (method === 'GET' && requestUrl.pathname === '/') {
|
|
1255
|
-
const address = server.address();
|
|
1256
|
-
const port = typeof address === 'object' && address ? address.port : options.port ?? 0;
|
|
1257
|
-
const host = typeof address === 'object' && address && address.address ? address.address : options.host;
|
|
1258
|
-
sendHtml(response, renderDashboardHtml(`http://${host === '::' ? '127.0.0.1' : host}:${port}`));
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
if (method === 'GET' && requestUrl.pathname === '/api/status') {
|
|
1262
|
-
sendJson(response, 200, {
|
|
1263
|
-
ok: true,
|
|
1264
|
-
service: 'stratus-dashboard',
|
|
1265
|
-
version: CLI_VERSION,
|
|
1266
|
-
now: new Date().toISOString(),
|
|
1267
|
-
});
|
|
1268
|
-
return;
|
|
1269
|
-
}
|
|
1270
|
-
if (method === 'POST' && requestUrl.pathname === '/api/echo') {
|
|
1271
|
-
const raw = await readBody(request);
|
|
1272
|
-
let text = '';
|
|
1273
|
-
if (raw.trim().length > 0) {
|
|
1274
|
-
const payload = JSON.parse(raw);
|
|
1275
|
-
text = typeof payload.text === 'string' ? payload.text : '';
|
|
1276
|
-
}
|
|
1277
|
-
sendJson(response, 200, {
|
|
1278
|
-
ok: true,
|
|
1279
|
-
received: text,
|
|
1280
|
-
uppercase: text.toUpperCase(),
|
|
1281
|
-
length: text.length,
|
|
1282
|
-
});
|
|
1283
|
-
return;
|
|
1284
|
-
}
|
|
1285
|
-
sendJson(response, 404, { ok: false, error: 'Not found' });
|
|
1286
|
-
});
|
|
1287
|
-
await new Promise((resolve, reject) => {
|
|
1288
|
-
server.once('error', reject);
|
|
1289
|
-
server.listen(options.port ?? 0, options.host, () => {
|
|
1290
|
-
server.off('error', reject);
|
|
1291
|
-
resolve();
|
|
1292
|
-
});
|
|
1293
|
-
});
|
|
1294
|
-
const address = server.address();
|
|
1295
|
-
if (!address || typeof address === 'string') {
|
|
1296
|
-
throw new Error('Unable to determine dashboard server address.');
|
|
1297
|
-
}
|
|
1298
|
-
const host = address.address === '::' ? '127.0.0.1' : address.address;
|
|
1299
|
-
const url = `http://${host}:${address.port}`;
|
|
1300
|
-
return {
|
|
1301
|
-
url,
|
|
1302
|
-
close: async () => {
|
|
1303
|
-
if (!server.listening) {
|
|
1304
|
-
return;
|
|
1305
|
-
}
|
|
1306
|
-
await new Promise((resolve, reject) => {
|
|
1307
|
-
server.close((error) => {
|
|
1308
|
-
if (error) {
|
|
1309
|
-
reject(error);
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
resolve();
|
|
1313
|
-
});
|
|
1314
|
-
});
|
|
1315
|
-
},
|
|
1316
|
-
};
|
|
1317
|
-
};
|
|
1318
1263
|
// Printed commands must survive copy-paste into a shell, so anything outside
|
|
1319
1264
|
// the safe character set gets single-quoted.
|
|
1320
1265
|
const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
@@ -1622,34 +1567,6 @@ const createSetupPrompter = (streams, env, view) => {
|
|
|
1622
1567
|
},
|
|
1623
1568
|
};
|
|
1624
1569
|
};
|
|
1625
|
-
// Live check that a pasted key actually works, so the user finds out inside
|
|
1626
|
-
// setup instead of on their first run.
|
|
1627
|
-
const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
|
|
1628
|
-
if (typeof fetchImpl !== 'function') {
|
|
1629
|
-
return { status: 'unreachable', detail: 'fetch is unavailable' };
|
|
1630
|
-
}
|
|
1631
|
-
const root = (baseUrl ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL)).replace(/\/+$/, '');
|
|
1632
|
-
const url = provider === 'anthropic' ? `${root}/v1/models` : `${root}/models`;
|
|
1633
|
-
const headers = provider === 'anthropic'
|
|
1634
|
-
? { 'x-api-key': key, 'anthropic-version': '2023-06-01' }
|
|
1635
|
-
: { authorization: `Bearer ${key}` };
|
|
1636
|
-
try {
|
|
1637
|
-
const response = await fetchImpl(url, { headers });
|
|
1638
|
-
if (response.ok) {
|
|
1639
|
-
return { status: 'ok' };
|
|
1640
|
-
}
|
|
1641
|
-
// Only an explicit auth failure condemns the key. Compatible endpoints
|
|
1642
|
-
// (local models, proxies) often lack GET /models entirely — a 404/405
|
|
1643
|
-
// there says nothing about the key, so it stays saveable.
|
|
1644
|
-
if (response.status === 401 || response.status === 403) {
|
|
1645
|
-
return { status: 'rejected', detail: `HTTP ${response.status}` };
|
|
1646
|
-
}
|
|
1647
|
-
return { status: 'unreachable', detail: `the endpoint did not support a key check (HTTP ${response.status})` };
|
|
1648
|
-
}
|
|
1649
|
-
catch (error) {
|
|
1650
|
-
return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
|
|
1651
|
-
}
|
|
1652
|
-
};
|
|
1653
1570
|
// Slack verification talks to the Web API over plain fetch rather than
|
|
1654
1571
|
// through @stratusagent/channel-slack. Setup must stay usable before that
|
|
1655
1572
|
// optional package is installed, and the CLI deliberately does not depend
|
|
@@ -1748,116 +1665,6 @@ const DEFAULT_SOUL_STARTER = [
|
|
|
1748
1665
|
'keep replies short unless the question genuinely needs depth. Use',
|
|
1749
1666
|
'memory.remember for durable facts about the people you work with.',
|
|
1750
1667
|
].join('\n');
|
|
1751
|
-
// Shown when live model listing is unavailable (e.g. subscription tokens
|
|
1752
|
-
// cannot call the models endpoint, or the machine is offline).
|
|
1753
|
-
// Model ids that cannot serve /chat/completions and must not become the
|
|
1754
|
-
// default: embeddings, audio, images, moderation, and legacy completions.
|
|
1755
|
-
const NON_CHAT_MODEL_PATTERN = /embed|whisper|tts|audio|dall-e|image|moderation|realtime|transcribe|davinci|babbage|curie|(^|[-_])ada([-_]|$)/i;
|
|
1756
|
-
const KNOWN_CLAUDE_MODELS = [
|
|
1757
|
-
'claude-opus-5',
|
|
1758
|
-
'claude-sonnet-5',
|
|
1759
|
-
'claude-haiku-4-5',
|
|
1760
|
-
'claude-opus-4-6',
|
|
1761
|
-
'claude-sonnet-4-6',
|
|
1762
|
-
];
|
|
1763
|
-
/**
|
|
1764
|
-
* The ids a newly created soul must not claim: every id the served roster
|
|
1765
|
-
* holds, which is three things and not one directory.
|
|
1766
|
-
*
|
|
1767
|
-
* - **What the roster files declare.** A filename is not an id: a soul at
|
|
1768
|
-
* `renamed.md` may declare `id: ava`, so `ava.md` being free proves
|
|
1769
|
-
* nothing — and since a duplicate refuses the whole roster, writing one
|
|
1770
|
-
* would hand back an agent whose daemon cannot start.
|
|
1771
|
-
* - **The configured default soul**, which the daemon registers whether or
|
|
1772
|
-
* not its file lives in the agents directory. It wins a same-id contest
|
|
1773
|
-
* with a roster file — `defaultAgentId` replaces the source when the
|
|
1774
|
-
* path differs — so a new agent sharing its id is not refused, it is
|
|
1775
|
-
* shadowed: created, then undispatchable by id or from Slack, with
|
|
1776
|
-
* nothing saying so.
|
|
1777
|
-
* - **The reserved `stratus`**, since a roster soul claiming it is skipped
|
|
1778
|
-
* at load, so writing one creates an agent that silently never appears.
|
|
1779
|
-
*
|
|
1780
|
-
* The two readable sources fail **independently**, and what they return is
|
|
1781
|
-
* what they know rather than all-or-nothing. A configured soul that is
|
|
1782
|
-
* missing or mid-edit does not stop the daemon serving the roster, so it
|
|
1783
|
-
* must not discard the roster's claims either: collapsing both to unknown
|
|
1784
|
-
* would let this command write the very duplicate that refuses the roster
|
|
1785
|
-
* it could have read. `unread` names what could not be answered, so the
|
|
1786
|
-
* caller can say which check did not run instead of implying none did.
|
|
1787
|
-
*/
|
|
1788
|
-
const declaredAgentIds = async (env) => {
|
|
1789
|
-
const [roster, configured] = await Promise.allSettled([
|
|
1790
|
-
loadRosterSouls(env, () => { }),
|
|
1791
|
-
// Empty selection: the soul a run started here would resolve, by the
|
|
1792
|
-
// same precedence the daemon uses — never a second reading of it.
|
|
1793
|
-
resolveConfiguredSoul({}, env),
|
|
1794
|
-
]);
|
|
1795
|
-
const ids = new Set([DEFAULT_STRATUS_AGENT.id]);
|
|
1796
|
-
const unread = [];
|
|
1797
|
-
if (roster.status === 'fulfilled') {
|
|
1798
|
-
for (const entry of roster.value) {
|
|
1799
|
-
ids.add(entry.soul.agent.id);
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
else {
|
|
1803
|
-
unread.push('the roster');
|
|
1804
|
-
}
|
|
1805
|
-
if (configured.status === 'fulfilled') {
|
|
1806
|
-
if (configured.value) {
|
|
1807
|
-
ids.add(configured.value.soul.agent.id);
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
else {
|
|
1811
|
-
unread.push('the configured default soul');
|
|
1812
|
-
}
|
|
1813
|
-
return { ids, unread };
|
|
1814
|
-
};
|
|
1815
|
-
/**
|
|
1816
|
-
* Write a new soul under an id nothing else holds, and return the agent
|
|
1817
|
-
* that got written.
|
|
1818
|
-
*
|
|
1819
|
-
* The name stays theirs, but the id — the soul filename, the memory key,
|
|
1820
|
-
* the credential scope — must be unique: the suggestion pool is small, so
|
|
1821
|
-
* a repeat name would otherwise share an earlier agent's memory. Two
|
|
1822
|
-
* claims have to fail here, and only one of them is a filename:
|
|
1823
|
-
*
|
|
1824
|
-
* - An id another soul declares, whatever that soul is called on disk.
|
|
1825
|
-
* Since a duplicate refuses the whole roster, writing one would leave a
|
|
1826
|
-
* daemon that will not start — created by the command meant to help.
|
|
1827
|
-
* - The path itself, via `wx`, which makes the claim atomic against a
|
|
1828
|
-
* concurrent writer that the roster read above cannot see.
|
|
1829
|
-
*
|
|
1830
|
-
* On either, the id takes a fresh suffix and we try again — through the
|
|
1831
|
-
* shared bound, since appending a suffix to a maxed-out base would build
|
|
1832
|
-
* an id the validator refuses, turning a collision into a crash.
|
|
1833
|
-
*/
|
|
1834
|
-
const claimSoulFile = async (env, input, render, note) => {
|
|
1835
|
-
const { ids: taken, unread } = await declaredAgentIds(env);
|
|
1836
|
-
if (unread.length > 0) {
|
|
1837
|
-
note(`Note: could not read ${unread.join(' or ')}, so this id was not checked against the ids it declares.`);
|
|
1838
|
-
}
|
|
1839
|
-
let agent = defineAgent({ ...(input.name ? { name: input.name } : {}), instructions: input.instructions });
|
|
1840
|
-
const baseId = agent.id;
|
|
1841
|
-
for (;;) {
|
|
1842
|
-
const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
1843
|
-
if (!taken.has(agent.id)) {
|
|
1844
|
-
try {
|
|
1845
|
-
await writeFile(soulPath, render(agent), { flag: 'wx' });
|
|
1846
|
-
return { agent, soulPath };
|
|
1847
|
-
}
|
|
1848
|
-
catch (error) {
|
|
1849
|
-
if (error.code !== 'EEXIST') {
|
|
1850
|
-
throw error;
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
agent = defineAgent({
|
|
1855
|
-
id: agentIdWithSuffix(baseId, randomUUID().slice(0, 4)),
|
|
1856
|
-
...(input.name ? { name: input.name } : { name: agent.name }),
|
|
1857
|
-
instructions: input.instructions,
|
|
1858
|
-
});
|
|
1859
|
-
}
|
|
1860
|
-
};
|
|
1861
1668
|
export const runSetup = async (command, streams, env = {}) => {
|
|
1862
1669
|
const cwd = readWorkingDirectory(env);
|
|
1863
1670
|
const processEnv = readProcessEnv(env);
|
|
@@ -1960,7 +1767,9 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1960
1767
|
consumeNotices: () => recentNotices.splice(0),
|
|
1961
1768
|
});
|
|
1962
1769
|
const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
|
|
1963
|
-
|
|
1770
|
+
// Widened to CliProviderName only so setup can ask about the provider it
|
|
1771
|
+
// currently has selected, `demo` included; the rule itself is the shared one.
|
|
1772
|
+
const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'openai' ? 'openai' : 'anthropic');
|
|
1964
1773
|
const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
|
|
1965
1774
|
const providerSignInStatus = (provider) => {
|
|
1966
1775
|
const credential = state.credentials[provider];
|
|
@@ -2222,85 +2031,16 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2222
2031
|
await maybeSwitchDefault('anthropic');
|
|
2223
2032
|
}
|
|
2224
2033
|
};
|
|
2225
|
-
// Every model the current sign-ins can actually reach
|
|
2226
|
-
//
|
|
2227
|
-
//
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
// never the default provider's secret.
|
|
2236
|
-
const envKey = (provider === state.provider
|
|
2237
|
-
? readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
2238
|
-
?? (state.apiKeyEnv ? readNonEmptyString(processEnv[state.apiKeyEnv]) : undefined)
|
|
2239
|
-
: undefined)
|
|
2240
|
-
?? readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]);
|
|
2241
|
-
const credential = envKey ? undefined : state.credentials[provider];
|
|
2242
|
-
const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
|
|
2243
|
-
if (!apiKey && !credential) {
|
|
2244
|
-
continue;
|
|
2245
|
-
}
|
|
2246
|
-
if (provider === 'anthropic') {
|
|
2247
|
-
if (!apiKey || typeof fetchImpl !== 'function') {
|
|
2248
|
-
// Subscription tokens cannot call the models endpoint.
|
|
2249
|
-
models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
|
|
2250
|
-
continue;
|
|
2251
|
-
}
|
|
2252
|
-
// The same endpoint a real run uses: the stored key's bound URL is
|
|
2253
|
-
// authoritative, then a configured anthropic base URL (a proxy) —
|
|
2254
|
-
// never the official endpoint by accident.
|
|
2255
|
-
const anthropicRoot = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
2256
|
-
?? (state.provider === 'anthropic' ? state.baseUrl : undefined)
|
|
2257
|
-
?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, '');
|
|
2258
|
-
try {
|
|
2259
|
-
const response = await fetchImpl(`${anthropicRoot}/v1/models?limit=100`, {
|
|
2260
|
-
headers: { 'x-api-key': String(apiKey), 'anthropic-version': '2023-06-01' },
|
|
2261
|
-
});
|
|
2262
|
-
const payload = await response.json();
|
|
2263
|
-
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string');
|
|
2264
|
-
models.push(...(ids.length > 0 ? ids : KNOWN_CLAUDE_MODELS).map((id) => ({ provider, id })));
|
|
2265
|
-
}
|
|
2266
|
-
catch {
|
|
2267
|
-
models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
|
|
2268
|
-
}
|
|
2269
|
-
continue;
|
|
2270
|
-
}
|
|
2271
|
-
if (!apiKey || typeof fetchImpl !== 'function') {
|
|
2272
|
-
continue;
|
|
2273
|
-
}
|
|
2274
|
-
try {
|
|
2275
|
-
// A stored key's bound endpoint is authoritative, exactly as at run
|
|
2276
|
-
// time; only env-supplied keys follow the default provider's URL.
|
|
2277
|
-
const root = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
2278
|
-
?? (state.provider === 'openai' ? state.baseUrl : undefined)
|
|
2279
|
-
?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
|
|
2280
|
-
const response = await fetchImpl(`${root}/models`, {
|
|
2281
|
-
headers: { authorization: `Bearer ${String(apiKey)}` },
|
|
2282
|
-
});
|
|
2283
|
-
const payload = await response.json();
|
|
2284
|
-
const allIds = (payload.data ?? [])
|
|
2285
|
-
.map((entry) => entry.id)
|
|
2286
|
-
.filter((id) => typeof id === 'string');
|
|
2287
|
-
// Runs always call /chat/completions, so embedding, audio, image,
|
|
2288
|
-
// moderation, and legacy completion models would save a default
|
|
2289
|
-
// that cannot execute. If filtering leaves nothing (an exotic local
|
|
2290
|
-
// service), show everything rather than an empty menu.
|
|
2291
|
-
const chatIds = allIds.filter((id) => !NON_CHAT_MODEL_PATTERN.test(id));
|
|
2292
|
-
const ids = (chatIds.length > 0 ? chatIds : allIds).sort((a, b) => {
|
|
2293
|
-
const rank = (id) => (/^gpt/i.test(id) ? 0 : /^o\d/i.test(id) ? 1 : 2);
|
|
2294
|
-
return rank(a) - rank(b) || a.localeCompare(b);
|
|
2295
|
-
});
|
|
2296
|
-
models.push(...ids.map((id) => ({ provider, id })));
|
|
2297
|
-
}
|
|
2298
|
-
catch {
|
|
2299
|
-
// No reachable model list for this provider; skip it.
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
return models;
|
|
2303
|
-
};
|
|
2034
|
+
// Every model the current sign-ins can actually reach. The rule lives in
|
|
2035
|
+
// @stratusagent/state because the control API answers the same question —
|
|
2036
|
+
// setup passes the selection it is *holding* rather than the saved one, so
|
|
2037
|
+
// a key pasted a moment ago is already in play.
|
|
2038
|
+
const collectAvailableModels = () => collectModels({
|
|
2039
|
+
provider: state.provider,
|
|
2040
|
+
...(state.baseUrl !== undefined ? { baseUrl: state.baseUrl } : {}),
|
|
2041
|
+
...(state.apiKeyEnv !== undefined ? { apiKeyEnv: state.apiKeyEnv } : {}),
|
|
2042
|
+
credentials: state.credentials,
|
|
2043
|
+
}, env);
|
|
2304
2044
|
const pickModel = async (kind) => {
|
|
2305
2045
|
const available = await collectAvailableModels();
|
|
2306
2046
|
if (available.length === 0) {
|
|
@@ -2446,7 +2186,6 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2446
2186
|
const pin = state.provider !== 'demo'
|
|
2447
2187
|
? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) }
|
|
2448
2188
|
: {};
|
|
2449
|
-
await mkdir(agentsDirPath(env), { recursive: true });
|
|
2450
2189
|
const claimed = await claimSoulFile(env, { ...(name ? { name } : {}), instructions: persona }, (agent) => formatSoul({ agent, ...pin }), (message) => writeLine(streams.stdout, message));
|
|
2451
2190
|
const { agent, soulPath } = claimed;
|
|
2452
2191
|
state.soulPath = soulPath;
|
|
@@ -2904,8 +2643,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2904
2643
|
config.fallbackBaseUrl = state.fallbackBaseUrl;
|
|
2905
2644
|
}
|
|
2906
2645
|
}
|
|
2907
|
-
await
|
|
2908
|
-
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
2646
|
+
await saveConfigFile(configPath, config);
|
|
2909
2647
|
writeLine(streams.stdout);
|
|
2910
2648
|
writeLine(streams.stdout, `Wrote ${configPath}`);
|
|
2911
2649
|
// A project-local config in this directory outranks the global file for
|
|
@@ -3088,127 +2826,93 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3088
2826
|
prompter.close();
|
|
3089
2827
|
}
|
|
3090
2828
|
};
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
2829
|
+
/**
|
|
2830
|
+
* The bearer token for a gateway: an explicit flag, the environment, or the
|
|
2831
|
+
* token file this machine's daemon wrote.
|
|
2832
|
+
*
|
|
2833
|
+
* The file is the normal case and the reason `--gateway` needs no ceremony
|
|
2834
|
+
* locally. It is not always right, though: a gateway reached through a tunnel
|
|
2835
|
+
* has its own token, which is what the flag and the variable are for.
|
|
2836
|
+
*/
|
|
2837
|
+
const gatewayToken = async (env, explicit) => {
|
|
2838
|
+
const fromEnv = readNonEmptyString(readProcessEnv(env).STRATUS_GATEWAY_TOKEN);
|
|
2839
|
+
if (explicit || fromEnv) {
|
|
2840
|
+
return String(explicit ?? fromEnv);
|
|
3095
2841
|
}
|
|
3096
|
-
return firstLine.length > 78 ? `${firstLine.slice(0, 77)}…` : firstLine;
|
|
3097
|
-
};
|
|
3098
|
-
export const runAgents = async (command, streams, env = {}) => {
|
|
3099
|
-
// Fold any legacy per-directory memory in first so the counts below
|
|
3100
|
-
// reflect everything each agent actually remembers.
|
|
3101
|
-
await migrateLegacyMemory(env);
|
|
3102
|
-
const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
|
|
3103
|
-
const processEnv = readProcessEnv(env);
|
|
3104
|
-
// Listing must never be blocked by a broken config — it only feeds the
|
|
3105
|
-
// default marker and the "runs on" lines.
|
|
3106
|
-
const { config: activeConfig } = await discoverActiveConfig(env, (message) => {
|
|
3107
|
-
writeLine(streams.stderr, `Warning: ${message}.`);
|
|
3108
|
-
});
|
|
3109
|
-
const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
3110
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'));
|
|
3111
|
-
const envModel = readNonEmptyString(processEnv.STRATUS_MODEL)
|
|
3112
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL);
|
|
3113
|
-
// What a run as this soul would actually use right now — the same
|
|
3114
|
-
// precedence as resolveRuntimeConfig (env vars, soul hints, config,
|
|
3115
|
-
// demo), so a model-only or provider-only pin still resolves honestly.
|
|
3116
|
-
const runsOnFor = (soulProvider, soulModel) => {
|
|
3117
|
-
const provider = envProvider ?? soulProvider ?? activeConfig.provider ?? 'demo';
|
|
3118
|
-
if (provider === 'demo') {
|
|
3119
|
-
return { provider };
|
|
3120
|
-
}
|
|
3121
|
-
const soulModelApplies = soulProvider === undefined || soulProvider === provider;
|
|
3122
|
-
const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
|
|
3123
|
-
const model = envModel
|
|
3124
|
-
?? (soulModelApplies ? soulModel : undefined)
|
|
3125
|
-
?? (configModelApplies ? activeConfig.model : undefined)
|
|
3126
|
-
?? (provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
|
|
3127
|
-
return { provider, model };
|
|
3128
|
-
};
|
|
3129
|
-
const defaultSoulPath = readNonEmptyString(processEnv.STRATUS_SOUL)
|
|
3130
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
|
|
3131
|
-
?? activeConfig.soul;
|
|
3132
|
-
const resolvedDefaultSoul = defaultSoulPath
|
|
3133
|
-
? path.resolve(readWorkingDirectory(env), defaultSoulPath)
|
|
3134
|
-
: undefined;
|
|
3135
|
-
const listings = [];
|
|
3136
|
-
const addSoul = async (soulPath) => {
|
|
3137
|
-
let parsed;
|
|
3138
|
-
try {
|
|
3139
|
-
parsed = parseSoul(await readFile(soulPath, 'utf8'), { seed: soulPath });
|
|
3140
|
-
}
|
|
3141
|
-
catch (error) {
|
|
3142
|
-
writeLine(streams.stderr, `Warning: skipping ${soulPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3143
|
-
return;
|
|
3144
|
-
}
|
|
3145
|
-
const { agent } = parsed;
|
|
3146
|
-
const persona = personaSnippet(agent.instructions);
|
|
3147
|
-
listings.push({
|
|
3148
|
-
id: agent.id,
|
|
3149
|
-
name: agent.name,
|
|
3150
|
-
isDefault: soulPath === resolvedDefaultSoul,
|
|
3151
|
-
builtIn: false,
|
|
3152
|
-
soulPath,
|
|
3153
|
-
...(parsed.provider ? { provider: parsed.provider } : {}),
|
|
3154
|
-
...(parsed.model ? { model: parsed.model } : {}),
|
|
3155
|
-
runsOn: runsOnFor(parsed.provider, parsed.model),
|
|
3156
|
-
memories: (await memory.list(agent.id)).length,
|
|
3157
|
-
...(persona ? { persona } : {}),
|
|
3158
|
-
...(agent.avatar ? { avatar: `${agent.avatar.style} theme, hue ${agent.avatar.hue}, palette ${agent.avatar.palette.join(' ')}` } : {}),
|
|
3159
|
-
});
|
|
3160
|
-
};
|
|
3161
|
-
let rosterFiles = [];
|
|
3162
2842
|
try {
|
|
3163
|
-
|
|
3164
|
-
.filter((file) => file.endsWith('.md'))
|
|
3165
|
-
.sort()
|
|
3166
|
-
.map((file) => path.join(agentsDirPath(env), file));
|
|
2843
|
+
return (await readFile(gatewayTokenPath(env), 'utf8')).trim();
|
|
3167
2844
|
}
|
|
3168
2845
|
catch (error) {
|
|
3169
2846
|
if (error.code !== 'ENOENT') {
|
|
3170
2847
|
throw error;
|
|
3171
2848
|
}
|
|
2849
|
+
throw new Error(`No gateway token: ${gatewayTokenPath(env)} does not exist, and neither --token nor STRATUS_GATEWAY_TOKEN was set. `
|
|
2850
|
+
+ 'Start the daemon with `stratus serve` (or `stratus service install`) to create one, or pass the remote gateway\'s token.');
|
|
3172
2851
|
}
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
}
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
2852
|
+
};
|
|
2853
|
+
/** Ask a running gateway for its roster, as data. */
|
|
2854
|
+
const remoteAgentSummaries = async (command, env) => {
|
|
2855
|
+
const token = await gatewayToken(env, command.token);
|
|
2856
|
+
const base = String(command.gateway).replace(/\/+$/, '');
|
|
2857
|
+
const fetchImpl = env.fetch ?? globalThis.fetch;
|
|
2858
|
+
if (typeof fetchImpl !== 'function') {
|
|
2859
|
+
throw new Error('fetch is unavailable, so --gateway cannot reach a daemon from this runtime.');
|
|
2860
|
+
}
|
|
2861
|
+
let response;
|
|
2862
|
+
try {
|
|
2863
|
+
response = await fetchImpl(`${base}/api/v1/agents`, {
|
|
2864
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
catch (error) {
|
|
2868
|
+
throw new Error(`Could not reach the gateway at ${base} (${error instanceof Error ? error.message : String(error)}). `
|
|
2869
|
+
+ 'Is stratusd running, and does it have @stratusagent/control-api installed?');
|
|
2870
|
+
}
|
|
2871
|
+
if (response.status === 401 || response.status === 403) {
|
|
2872
|
+
throw new Error(`The gateway at ${base} rejected this token. Check --token, STRATUS_GATEWAY_TOKEN, or ~/.stratus/gateway-token.`);
|
|
2873
|
+
}
|
|
2874
|
+
if (!response.ok) {
|
|
2875
|
+
throw new Error(`The gateway at ${base} answered HTTP ${response.status} for the roster.`);
|
|
2876
|
+
}
|
|
2877
|
+
const payload = await response.json();
|
|
2878
|
+
return payload.agents ?? [];
|
|
2879
|
+
};
|
|
2880
|
+
export const runAgents = async (command, streams, env = {}) => {
|
|
2881
|
+
// The same listing either way — the shared builder produces it locally, and
|
|
2882
|
+
// the control API serves exactly what that builder produced. Rendering is
|
|
2883
|
+
// the only thing this command does with it.
|
|
2884
|
+
let listings;
|
|
2885
|
+
if (command.gateway) {
|
|
2886
|
+
listings = await remoteAgentSummaries(command, env);
|
|
2887
|
+
}
|
|
2888
|
+
else {
|
|
2889
|
+
// Fold any legacy per-directory memory in first so the counts below
|
|
2890
|
+
// reflect everything each agent actually remembers.
|
|
2891
|
+
await migrateLegacyMemory(env);
|
|
2892
|
+
listings = await listAgentSummaries(env, (message) => {
|
|
2893
|
+
writeLine(streams.stderr, `Warning: ${message}.`);
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
// The palette travels structurally now, because a web or macOS surface has
|
|
2897
|
+
// to draw it. This output has always been one prose line, and a shape
|
|
2898
|
+
// change here would break every script reading it — so the rendering, not
|
|
2899
|
+
// the data, stays where it was.
|
|
2900
|
+
const describeAvatar = (avatar) => `${avatar.style} theme, hue ${avatar.hue}, palette ${avatar.palette.join(' ')}`;
|
|
3201
2901
|
if (command.format === 'json') {
|
|
3202
2902
|
writeLine(streams.stdout, JSON.stringify({
|
|
3203
|
-
agents: listings.map(({ isDefault, ...rest }) => ({
|
|
2903
|
+
agents: listings.map(({ default: isDefault, avatar, ...rest }) => ({
|
|
2904
|
+
...rest,
|
|
2905
|
+
...(avatar ? { avatar: describeAvatar(avatar) } : {}),
|
|
2906
|
+
default: isDefault,
|
|
2907
|
+
})),
|
|
3204
2908
|
}, null, 2));
|
|
3205
2909
|
return 0;
|
|
3206
2910
|
}
|
|
3207
2911
|
const describeRunsOn = (runsOn) => (runsOn.provider === 'demo' ? 'demo (offline)' : `${runsOn.provider}${runsOn.model ? ` · ${runsOn.model}` : ''}`);
|
|
3208
|
-
writeLine(streams.stdout, 'Agents');
|
|
2912
|
+
writeLine(streams.stdout, command.gateway ? `Agents on ${command.gateway}` : 'Agents');
|
|
3209
2913
|
for (const agent of listings) {
|
|
3210
2914
|
const labels = [
|
|
3211
|
-
...(agent.
|
|
2915
|
+
...(agent.default ? ['default'] : []),
|
|
3212
2916
|
...(agent.builtIn ? ['built-in'] : []),
|
|
3213
2917
|
];
|
|
3214
2918
|
writeLine(streams.stdout);
|
|
@@ -3223,7 +2927,7 @@ export const runAgents = async (command, streams, env = {}) => {
|
|
|
3223
2927
|
writeLine(streams.stdout, ` persona ${agent.persona}`);
|
|
3224
2928
|
}
|
|
3225
2929
|
if (agent.avatar) {
|
|
3226
|
-
writeLine(streams.stdout, ` avatar ${agent.avatar}`);
|
|
2930
|
+
writeLine(streams.stdout, ` avatar ${describeAvatar(agent.avatar)}`);
|
|
3227
2931
|
}
|
|
3228
2932
|
}
|
|
3229
2933
|
writeLine(streams.stdout);
|
|
@@ -3641,40 +3345,6 @@ const serviceEnvFor = (env) => ({
|
|
|
3641
3345
|
* profile, so the unit would come up unauthenticated while setup had just
|
|
3642
3346
|
* reported everything ready. Returns the variable to name, if so.
|
|
3643
3347
|
*/
|
|
3644
|
-
/**
|
|
3645
|
-
* Every runtime the daemon would resolve: the config-wide default plus one
|
|
3646
|
-
* per roster soul, normalized the way a dispatch normalizes it. A soul that
|
|
3647
|
-
* pins its own provider resolves to different credentials entirely, so any
|
|
3648
|
-
* check that looks only at the default misses exactly the agent that is
|
|
3649
|
-
* misconfigured.
|
|
3650
|
-
*/
|
|
3651
|
-
const servedRuntimes = async (env, configPath) => {
|
|
3652
|
-
const { applySoulPins } = await import('@stratusagent/gateway');
|
|
3653
|
-
const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
|
|
3654
|
-
const context = {
|
|
3655
|
-
...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
|
|
3656
|
-
configPresent: location !== undefined,
|
|
3657
|
-
};
|
|
3658
|
-
const passes = [{ selection: {}, env }];
|
|
3659
|
-
// A roster that will not load is the gateway's to refuse, with a better
|
|
3660
|
-
// message than this preflight could give — so it checks what it can and
|
|
3661
|
-
// leaves the failing to start().
|
|
3662
|
-
const rosterForRuntimes = await loadRosterSouls(env, () => { }).catch(() => []);
|
|
3663
|
-
for (const entry of rosterForRuntimes) {
|
|
3664
|
-
const normalized = applySoulPins(entry.soul, {}, env, context);
|
|
3665
|
-
passes.push({ selection: normalized.selection, env: normalized.env });
|
|
3666
|
-
}
|
|
3667
|
-
const resolved = [];
|
|
3668
|
-
for (const pass of passes) {
|
|
3669
|
-
// A runtime that cannot resolve is the gateway's to report, per
|
|
3670
|
-
// dispatch and with far better context than a startup pass has.
|
|
3671
|
-
const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
|
|
3672
|
-
if (runtime) {
|
|
3673
|
-
resolved.push({ runtime, env: pass.env });
|
|
3674
|
-
}
|
|
3675
|
-
}
|
|
3676
|
-
return resolved;
|
|
3677
|
-
};
|
|
3678
3348
|
/**
|
|
3679
3349
|
* The daemon's `approvals` block, from the config file the daemon itself
|
|
3680
3350
|
* would load. Discovery goes through the shared resolver rather than
|
|
@@ -3697,26 +3367,17 @@ const servedRuntimes = async (env, configPath) => {
|
|
|
3697
3367
|
* fleet down over a policy block that may not even be present.
|
|
3698
3368
|
*/
|
|
3699
3369
|
const loadServeApprovals = async (env, configPath, warn) => {
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
}
|
|
3705
|
-
const approvals = (await loadConfigFile(location.path)).approvals;
|
|
3706
|
-
if (!approvals) {
|
|
3707
|
-
return {};
|
|
3708
|
-
}
|
|
3709
|
-
if (!location.trusted) {
|
|
3710
|
-
warn(`ignoring the approvals config in ${location.path}: a project-local config cannot decide who may approve `
|
|
3711
|
-
+ 'this daemon\'s tool calls. Move it to ~/.stratus/config.json, or pass it with --config.');
|
|
3712
|
-
return {};
|
|
3713
|
-
}
|
|
3714
|
-
return approvals;
|
|
3370
|
+
const block = await readTrustedConfigBlock('approvals', env, configPath);
|
|
3371
|
+
if (block.status === 'untrusted') {
|
|
3372
|
+
warn(`ignoring the approvals config in ${block.path}: a project-local config cannot decide who may approve `
|
|
3373
|
+
+ 'this daemon\'s tool calls. Move it to ~/.stratus/config.json, or pass it with --config.');
|
|
3374
|
+
return {};
|
|
3715
3375
|
}
|
|
3716
|
-
|
|
3717
|
-
warn(`ignoring the approvals config (${error instanceof Error ? error.message : String(error)}); refusing gated calls`);
|
|
3376
|
+
if (block.status === 'unreadable') {
|
|
3377
|
+
warn(`ignoring the approvals config (${block.error instanceof Error ? block.error.message : String(block.error)}); refusing gated calls`);
|
|
3718
3378
|
return {};
|
|
3719
3379
|
}
|
|
3380
|
+
return block.status === 'present' ? block.value : {};
|
|
3720
3381
|
};
|
|
3721
3382
|
/**
|
|
3722
3383
|
* One line naming who can actually answer, so a remote daemon does not look
|
|
@@ -3883,7 +3544,6 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3883
3544
|
?? (configModelApplies ? activeConfig.model : undefined)
|
|
3884
3545
|
?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
|
|
3885
3546
|
const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
|
|
3886
|
-
await mkdir(agentsDirPath(env), { recursive: true });
|
|
3887
3547
|
const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
|
|
3888
3548
|
const { agent, soulPath } = claimed;
|
|
3889
3549
|
const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
|
|
@@ -3908,9 +3568,16 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3908
3568
|
}
|
|
3909
3569
|
}
|
|
3910
3570
|
if (globalConfig !== undefined) {
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3571
|
+
// Re-validated rather than cast: `readNonEmptyString` widens to
|
|
3572
|
+
// `string`, and the shared parser is what says which strings are
|
|
3573
|
+
// provider names. Every source feeding soulProvider is already one,
|
|
3574
|
+
// so this narrows without being able to throw.
|
|
3575
|
+
const config = {
|
|
3576
|
+
...globalConfig,
|
|
3577
|
+
provider: globalConfig.provider ?? parseProviderName(soulProvider, 'provider'),
|
|
3578
|
+
soul: soulPath,
|
|
3579
|
+
};
|
|
3580
|
+
await saveConfigFile(globalConfigPath(env), config);
|
|
3914
3581
|
madeDefault = true;
|
|
3915
3582
|
if (configLocation && configLocation.path !== globalConfigPath(env)) {
|
|
3916
3583
|
writeLine(streams.stdout, `Note: ${configLocation.path} takes precedence over the global config for runs started in this directory.`);
|
|
@@ -3959,30 +3626,166 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3959
3626
|
writeLine(streams.stdout, JSON.stringify(agent, null, 2));
|
|
3960
3627
|
return 0;
|
|
3961
3628
|
};
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3629
|
+
const readGatewayInfo = async (env) => {
|
|
3630
|
+
try {
|
|
3631
|
+
const parsed = JSON.parse(await readFile(gatewayInfoPath(env), 'utf8'));
|
|
3632
|
+
return typeof parsed.url === 'string' ? { url: parsed.url, ...(parsed.pid ? { pid: parsed.pid } : {}) } : undefined;
|
|
3633
|
+
}
|
|
3634
|
+
catch {
|
|
3635
|
+
// No file, or one left behind by a daemon that died without cleaning up.
|
|
3636
|
+
// Either way there is nothing to talk to until the health check says so.
|
|
3637
|
+
return undefined;
|
|
3638
|
+
}
|
|
3639
|
+
};
|
|
3640
|
+
/**
|
|
3641
|
+
* A one-time URL for the browser.
|
|
3642
|
+
*
|
|
3643
|
+
* This is the whole reason the exchange exists: the CLI can read the token
|
|
3644
|
+
* file and a page cannot, so the CLI lends its authority for exactly one
|
|
3645
|
+
* short-lived trip.
|
|
3646
|
+
*/
|
|
3647
|
+
const mintDashboardUrl = async (env, base, fetchImpl) => {
|
|
3648
|
+
const token = await gatewayToken(env, undefined);
|
|
3649
|
+
const response = await fetchImpl(`${base}/api/v1/auth/ott`, {
|
|
3650
|
+
method: 'POST',
|
|
3651
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3966
3652
|
});
|
|
3967
|
-
|
|
3968
|
-
|
|
3653
|
+
if (!response.ok) {
|
|
3654
|
+
throw new Error(`The gateway at ${base} refused to open a dashboard session (HTTP ${response.status}).`);
|
|
3655
|
+
}
|
|
3656
|
+
const payload = await response.json();
|
|
3657
|
+
// The relative form joined to the base we already reached, in preference to
|
|
3658
|
+
// the absolute one: this command knows exactly which address answered, and
|
|
3659
|
+
// the daemon can only infer it from headers.
|
|
3660
|
+
if (payload.path) {
|
|
3661
|
+
return `${base.replace(/\/+$/, '')}${payload.path}`;
|
|
3662
|
+
}
|
|
3663
|
+
if (!payload.url) {
|
|
3664
|
+
throw new Error(`The gateway at ${base} did not return a dashboard URL.`);
|
|
3665
|
+
}
|
|
3666
|
+
return payload.url;
|
|
3667
|
+
};
|
|
3668
|
+
/** Whether something is actually answering there, as opposed to a stale file. */
|
|
3669
|
+
const gatewayAnswering = async (env, base, fetchImpl) => {
|
|
3670
|
+
try {
|
|
3671
|
+
const token = await gatewayToken(env, undefined);
|
|
3672
|
+
const response = await fetchImpl(`${base}/api/v1/health`, {
|
|
3673
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3674
|
+
});
|
|
3675
|
+
return response.ok;
|
|
3676
|
+
}
|
|
3677
|
+
catch {
|
|
3678
|
+
return false;
|
|
3679
|
+
}
|
|
3680
|
+
};
|
|
3681
|
+
/**
|
|
3682
|
+
* `stratus dashboard` — open the web UI against a running daemon, starting
|
|
3683
|
+
* one in the foreground when there is none.
|
|
3684
|
+
*
|
|
3685
|
+
* The daemon is started by calling `runServe`, not by rebuilding its wiring:
|
|
3686
|
+
* channels, approvals, the credential preflight, and the log writer are all
|
|
3687
|
+
* decisions `serve` already makes, and a second copy of them here would drift
|
|
3688
|
+
* the first time either side gained a rule.
|
|
3689
|
+
*/
|
|
3690
|
+
export const runDashboard = async (command, streams, env = {}) => {
|
|
3691
|
+
const fetchImpl = env.fetch ?? globalThis.fetch;
|
|
3692
|
+
if (typeof fetchImpl !== 'function') {
|
|
3693
|
+
writeLine(streams.stderr, 'Error: fetch is unavailable, so the dashboard cannot reach a gateway.');
|
|
3694
|
+
return 1;
|
|
3695
|
+
}
|
|
3696
|
+
const existing = await readGatewayInfo(env);
|
|
3697
|
+
let base = existing && await gatewayAnswering(env, existing.url, fetchImpl) ? existing.url : undefined;
|
|
3698
|
+
const ownDaemon = new AbortController();
|
|
3699
|
+
let serving;
|
|
3700
|
+
if (!base) {
|
|
3701
|
+
writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
|
|
3702
|
+
writeLine(streams.stdout, 'Run `stratus service install` to keep one running instead.');
|
|
3703
|
+
serving = runServe({
|
|
3704
|
+
command: 'serve',
|
|
3705
|
+
events: false,
|
|
3706
|
+
// Explicitly on. A trusted config may set `api.enabled: false` — a
|
|
3707
|
+
// reasonable thing for a headless box — but this command exists to
|
|
3708
|
+
// open the dashboard, and honouring it here would start a daemon
|
|
3709
|
+
// with no API and then time out waiting for the one it promised.
|
|
3710
|
+
api: true,
|
|
3711
|
+
...(command.port !== undefined ? { apiPort: command.port } : {}),
|
|
3712
|
+
// Passed whenever it was asked for, including when it is the default.
|
|
3713
|
+
// `--host 127.0.0.1` against a config saying `0.0.0.0` is an operator
|
|
3714
|
+
// narrowing the bind, and dropping it because it matched the default
|
|
3715
|
+
// did the reverse of what they typed.
|
|
3716
|
+
...(command.host !== undefined ? { apiHost: command.host } : {}),
|
|
3717
|
+
},
|
|
3718
|
+
// The daemon's own chatter belongs on stderr here: stdout is where this
|
|
3719
|
+
// command says where to point a browser, and interleaving the two makes
|
|
3720
|
+
// the one line that matters hard to find.
|
|
3721
|
+
{ stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
|
|
3722
|
+
// Attached now, not at the end. `runServe` can reject before it publishes
|
|
3723
|
+
// anything — an unreadable roster, a session store that will not open —
|
|
3724
|
+
// and a rejected promise nobody is watching for fifteen seconds is an
|
|
3725
|
+
// unhandled rejection, which terminates the process instead of reaching
|
|
3726
|
+
// the message below. Captured rather than swallowed, so the reason the
|
|
3727
|
+
// daemon gave is the reason this command reports.
|
|
3728
|
+
let daemonFailure;
|
|
3729
|
+
const daemonExit = serving.then(() => undefined, (error) => { daemonFailure = error; });
|
|
3730
|
+
// Gated on the daemon actually answering, not on a delay: it publishes
|
|
3731
|
+
// where it bound the moment it binds, and anything less would be a race
|
|
3732
|
+
// dressed up as a timeout. It also stops the moment the daemon gives up,
|
|
3733
|
+
// rather than waiting out a timeout for a process that is already gone.
|
|
3734
|
+
const startedBy = Date.now() + 15_000;
|
|
3735
|
+
let daemonStopped = false;
|
|
3736
|
+
void daemonExit.then(() => { daemonStopped = true; });
|
|
3737
|
+
while (!base && !daemonStopped && Date.now() < startedBy) {
|
|
3738
|
+
const info = await readGatewayInfo(env);
|
|
3739
|
+
if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
|
|
3740
|
+
base = info.url;
|
|
3741
|
+
break;
|
|
3742
|
+
}
|
|
3743
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
3744
|
+
}
|
|
3745
|
+
if (!base) {
|
|
3746
|
+
ownDaemon.abort();
|
|
3747
|
+
await daemonExit;
|
|
3748
|
+
writeLine(streams.stderr, daemonFailure
|
|
3749
|
+
? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
|
|
3750
|
+
: 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
|
|
3751
|
+
return 1;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
let url;
|
|
3755
|
+
try {
|
|
3756
|
+
url = await mintDashboardUrl(env, base, fetchImpl);
|
|
3757
|
+
}
|
|
3758
|
+
catch (error) {
|
|
3759
|
+
ownDaemon.abort();
|
|
3760
|
+
await serving?.catch(() => 0);
|
|
3761
|
+
writeLine(streams.stderr, `Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
3762
|
+
return 1;
|
|
3763
|
+
}
|
|
3764
|
+
writeLine(streams.stdout, `${DASHBOARD_TITLE} ready at ${base}`);
|
|
3765
|
+
writeLine(streams.stdout, 'That link signs one browser in and can only be used once.');
|
|
3969
3766
|
if (command.openBrowser) {
|
|
3970
3767
|
try {
|
|
3971
|
-
await (env.openExternal ?? openExternalUrl)(
|
|
3768
|
+
await (env.openExternal ?? openExternalUrl)(url);
|
|
3972
3769
|
writeLine(streams.stdout, 'Opened your default browser.');
|
|
3973
3770
|
}
|
|
3974
3771
|
catch (error) {
|
|
3975
3772
|
writeLine(streams.stderr, `Warning: Could not open the browser automatically: ${error instanceof Error ? error.message : String(error)}`);
|
|
3773
|
+
writeLine(streams.stdout, `Open this yourself: ${url}`);
|
|
3976
3774
|
}
|
|
3977
3775
|
}
|
|
3776
|
+
else {
|
|
3777
|
+
writeLine(streams.stdout, `Open this to sign in: ${url}`);
|
|
3778
|
+
}
|
|
3779
|
+
if (!serving) {
|
|
3780
|
+
// Someone else owns the daemon; this command's job is done.
|
|
3781
|
+
return 0;
|
|
3782
|
+
}
|
|
3783
|
+
writeLine(streams.stdout, 'Press Ctrl+C to stop the daemon.');
|
|
3978
3784
|
if (env.dashboardAutoShutdownMs !== undefined) {
|
|
3979
3785
|
await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
|
|
3980
|
-
|
|
3981
|
-
return 0;
|
|
3786
|
+
ownDaemon.abort();
|
|
3982
3787
|
}
|
|
3983
|
-
|
|
3984
|
-
await handle.close();
|
|
3985
|
-
return 0;
|
|
3788
|
+
return serving;
|
|
3986
3789
|
};
|
|
3987
3790
|
/**
|
|
3988
3791
|
* Loads the optional Slack channel package, or undefined when it is not
|
|
@@ -4009,6 +3812,65 @@ const loadSlackAdapter = async () => {
|
|
|
4009
3812
|
// installed package, and surfaces.
|
|
4010
3813
|
return (await import('@stratusagent/channel-slack')).createSlackChannelAdapter;
|
|
4011
3814
|
};
|
|
3815
|
+
/**
|
|
3816
|
+
* Loads the optional control-API package, or undefined when it is not
|
|
3817
|
+
* installed. Same two-step as the Slack adapter, and for the same reason:
|
|
3818
|
+
* only the package being absent means "not installed". A package that IS
|
|
3819
|
+
* installed but is missing one of its own dependencies throws
|
|
3820
|
+
* ERR_MODULE_NOT_FOUND naming that dependency, so inspecting the message
|
|
3821
|
+
* would read a broken install as an absent one — silently leaving a daemon
|
|
3822
|
+
* without the API an operator installed it to have.
|
|
3823
|
+
*/
|
|
3824
|
+
const loadControlApi = async () => {
|
|
3825
|
+
try {
|
|
3826
|
+
import.meta.resolve('@stratusagent/control-api');
|
|
3827
|
+
}
|
|
3828
|
+
catch {
|
|
3829
|
+
return undefined;
|
|
3830
|
+
}
|
|
3831
|
+
return (await import('@stratusagent/control-api')).createControlApi;
|
|
3832
|
+
};
|
|
3833
|
+
/**
|
|
3834
|
+
* The daemon's `api` block, from the config file the daemon itself would
|
|
3835
|
+
* load — and only from a trusted location.
|
|
3836
|
+
*
|
|
3837
|
+
* An auto-discovered project-local `stratus.config.json` outranks the global
|
|
3838
|
+
* one and can be checked into any repository. Which interface a daemon binds
|
|
3839
|
+
* is exactly the kind of decision a cloned repo must not get to make, so an
|
|
3840
|
+
* untrusted config's block is ignored loudly rather than obeyed.
|
|
3841
|
+
*/
|
|
3842
|
+
const loadServeApi = async (env, configPath, warn) => {
|
|
3843
|
+
const block = await readTrustedConfigBlock('api', env, configPath);
|
|
3844
|
+
if (block.status === 'untrusted') {
|
|
3845
|
+
warn(`ignoring the api config in ${block.path}: a project-local config cannot decide which interface this `
|
|
3846
|
+
+ 'daemon binds. Move it to ~/.stratus/config.json, or pass it with --config.');
|
|
3847
|
+
return {};
|
|
3848
|
+
}
|
|
3849
|
+
if (block.status === 'unreadable') {
|
|
3850
|
+
warn(`ignoring the api config (${block.error instanceof Error ? block.error.message : String(block.error)}); using the defaults`);
|
|
3851
|
+
return {};
|
|
3852
|
+
}
|
|
3853
|
+
return block.status === 'present' ? block.value : {};
|
|
3854
|
+
};
|
|
3855
|
+
/**
|
|
3856
|
+
* The daemon's `plugins` block — the same trust boundary as the two above,
|
|
3857
|
+
* and the one it was written for. A plugin runs in-process with the daemon,
|
|
3858
|
+
* so a list of them is a list of code; a `stratus.config.json` that ships
|
|
3859
|
+
* in a cloned repository must not be able to write it.
|
|
3860
|
+
*/
|
|
3861
|
+
const loadServePlugins = async (env, configPath, warn) => {
|
|
3862
|
+
const block = await readTrustedConfigBlock('plugins', env, configPath);
|
|
3863
|
+
if (block.status === 'untrusted') {
|
|
3864
|
+
warn(`ignoring the plugins config in ${block.path}: a project-local config cannot decide which code runs inside `
|
|
3865
|
+
+ 'this daemon. Move it to ~/.stratus/config.json, or pass it with --config.');
|
|
3866
|
+
return {};
|
|
3867
|
+
}
|
|
3868
|
+
if (block.status === 'unreadable') {
|
|
3869
|
+
warn(`ignoring the plugins config (${block.error instanceof Error ? block.error.message : String(block.error)}); loading no plugins`);
|
|
3870
|
+
return {};
|
|
3871
|
+
}
|
|
3872
|
+
return block.status === 'present' ? block.value : {};
|
|
3873
|
+
};
|
|
4012
3874
|
/**
|
|
4013
3875
|
* `stratus serve` — run the gateway (stratusd) in the foreground: load the
|
|
4014
3876
|
* roster, accept dispatches, print events, and drain cleanly on SIGTERM,
|
|
@@ -4053,9 +3915,53 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4053
3915
|
// approve this" differently from "is anyone being asked at all".
|
|
4054
3916
|
const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
|
|
4055
3917
|
const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
|
|
3918
|
+
// Read here rather than inside the gateway for the same reason as the two
|
|
3919
|
+
// blocks above: the trust boundary is a property of *which file* said it,
|
|
3920
|
+
// and this is where the file precedence is already understood.
|
|
3921
|
+
const pluginsConfig = await loadServePlugins(env, command.configPath, warn);
|
|
3922
|
+
// The control API is a channel adapter like any other: started after the
|
|
3923
|
+
// roster loads, stopped before the store drains. It is optional because
|
|
3924
|
+
// installing it is how an operator says they want a port open.
|
|
3925
|
+
const apiConfig = await loadServeApi(env, command.configPath, warn);
|
|
3926
|
+
const apiWanted = command.api ?? apiConfig.enabled ?? true;
|
|
3927
|
+
// Typed as the seam, not as whatever the first push happens to be: the
|
|
3928
|
+
// list holds the control API and the Slack adapter alike.
|
|
3929
|
+
const controlApiChannels = [];
|
|
3930
|
+
if (apiWanted) {
|
|
3931
|
+
const createControlApi = await loadControlApi();
|
|
3932
|
+
// Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
|
|
3933
|
+
// `a ?? (b !== undefined)` — so an explicit `--api-port 0`, which is how
|
|
3934
|
+
// you ask for any free port, was falsy and fell through to the default.
|
|
3935
|
+
// Everything still bound and every test still passed, on whichever port
|
|
3936
|
+
// nobody happened to be using.
|
|
3937
|
+
const apiHost = command.apiHost ?? apiConfig.host;
|
|
3938
|
+
const apiPort = command.apiPort ?? apiConfig.port;
|
|
3939
|
+
if (createControlApi) {
|
|
3940
|
+
controlApiChannels.push(createControlApi({
|
|
3941
|
+
env,
|
|
3942
|
+
...(apiHost !== undefined ? { host: apiHost } : {}),
|
|
3943
|
+
...(apiPort !== undefined ? { port: apiPort } : {}),
|
|
3944
|
+
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
3945
|
+
log,
|
|
3946
|
+
warn,
|
|
3947
|
+
}));
|
|
3948
|
+
}
|
|
3949
|
+
else if (command.api === true || apiConfig.enabled === true) {
|
|
3950
|
+
// Only when someone asked for it explicitly. A daemon that was never
|
|
3951
|
+
// told to serve an API should not complain about not having one.
|
|
3952
|
+
warn('the control API was requested, but @stratusagent/control-api is not installed. '
|
|
3953
|
+
+ 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
4056
3956
|
const channelCredentials = await loadChannelCredentials(env);
|
|
4057
3957
|
const slackAgents = Object.entries(channelCredentials.slack ?? {});
|
|
4058
|
-
const channels = [];
|
|
3958
|
+
const channels = [...controlApiChannels];
|
|
3959
|
+
// Tracked on its own, never as `channels.length`: the list now holds the
|
|
3960
|
+
// control API too, and "can anyone be asked for an approval" is a question
|
|
3961
|
+
// about the Slack adapter specifically. Reading it off the list length
|
|
3962
|
+
// would tell a daemon with an API and no Slack app that every agent is
|
|
3963
|
+
// reachable, and its gated calls would park with nobody rendering them.
|
|
3964
|
+
let slackAdapterUp = false;
|
|
4059
3965
|
if (slackAgents.length > 0) {
|
|
4060
3966
|
// Channel packages are optional peers: the CLI never bundles a
|
|
4061
3967
|
// transport nobody asked for (the Slack SDKs alone are ~9 MB). A
|
|
@@ -4064,6 +3970,7 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4064
3970
|
// module-not-found stack.
|
|
4065
3971
|
const adapter = await loadSlackAdapter();
|
|
4066
3972
|
if (adapter) {
|
|
3973
|
+
slackAdapterUp = true;
|
|
4067
3974
|
channels.push(adapter({
|
|
4068
3975
|
agents: slackAgents.map(([agentId, tokens]) => {
|
|
4069
3976
|
const route = resolveAgentApprovals(approvalsConfig, agentId);
|
|
@@ -4138,19 +4045,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4138
4045
|
// A factory, not a policy: remote mode parks turns on a transport the
|
|
4139
4046
|
// gateway owns, so the policy cannot exist until the gateway is building
|
|
4140
4047
|
// it. Headless takes the same path so there is one construction site.
|
|
4048
|
+
// The command-scope engine, which only has a caller once a shell pack is
|
|
4049
|
+
// installed. Wired unconditionally because it costs nothing without one:
|
|
4050
|
+
// a tool that carries no command string is judged by its risk exactly as
|
|
4051
|
+
// before. The whitelist lives beside the agent's soul, per agent.
|
|
4052
|
+
const commands = {
|
|
4053
|
+
whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env) }),
|
|
4054
|
+
onScopeRemembered: ({ agentId, scope }) => {
|
|
4055
|
+
// An approval that widens what runs unattended, for every future
|
|
4056
|
+
// session, is precisely the decision that must not be the one leaving
|
|
4057
|
+
// no trace.
|
|
4058
|
+
log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
|
|
4059
|
+
},
|
|
4060
|
+
};
|
|
4141
4061
|
const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
|
|
4142
|
-
? { mode: 'remote', request: transport.request, onDecision }
|
|
4143
|
-
: { mode: 'headless', onDecision });
|
|
4062
|
+
? { mode: 'remote', request: transport.request, onDecision, commands }
|
|
4063
|
+
: { mode: 'headless', onDecision, commands });
|
|
4144
4064
|
if (approvalMode === 'remote') {
|
|
4145
4065
|
// Only agents whose channel actually came up can be asked: tokens on
|
|
4146
4066
|
// disk with the Slack package missing means nothing renders the
|
|
4147
4067
|
// request, and the turn discovers that by hanging.
|
|
4148
|
-
const askable =
|
|
4068
|
+
const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
|
|
4149
4069
|
log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
|
|
4150
4070
|
}
|
|
4151
4071
|
const gateway = createGateway({
|
|
4152
4072
|
env,
|
|
4153
4073
|
approvals,
|
|
4074
|
+
...(Object.keys(pluginsConfig).length > 0
|
|
4075
|
+
? {
|
|
4076
|
+
plugins: pluginsConfig,
|
|
4077
|
+
// Resolved from *here*, not from inside the gateway.
|
|
4078
|
+
// `import.meta.resolve` answers relative to the module that calls
|
|
4079
|
+
// it, and a plugin is installed alongside the thing the operator
|
|
4080
|
+
// installed — this CLI. Letting the gateway resolve from its own
|
|
4081
|
+
// location works only where the layout happens to be flat.
|
|
4082
|
+
pluginHost: {
|
|
4083
|
+
resolve: (specifier) => import.meta.resolve(specifier),
|
|
4084
|
+
import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
|
|
4085
|
+
},
|
|
4086
|
+
}
|
|
4087
|
+
: {}),
|
|
4154
4088
|
...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
|
|
4155
4089
|
...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
|
|
4156
4090
|
...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
|
|
@@ -4226,7 +4160,7 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4226
4160
|
// another one is about to answer — so it is reported here, where the
|
|
4227
4161
|
// roster and the channel list are both in view.
|
|
4228
4162
|
if (approvalMode === 'remote') {
|
|
4229
|
-
const askable = new Set(
|
|
4163
|
+
const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
|
|
4230
4164
|
const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
|
|
4231
4165
|
if (unreachable.length > 0) {
|
|
4232
4166
|
warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
|
|
@@ -4342,6 +4276,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
4342
4276
|
runtime,
|
|
4343
4277
|
approvals: command.approvals,
|
|
4344
4278
|
...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
|
|
4279
|
+
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
4345
4280
|
env: resolvedEnv,
|
|
4346
4281
|
});
|
|
4347
4282
|
if (command.format === 'json') {
|