@stratusagent/cli 0.3.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 +430 -10
- package/dist/index.d.ts +51 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +767 -558
- 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
19
|
import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
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';
|
|
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.3.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
|
|
@@ -97,9 +100,13 @@ Commands:
|
|
|
97
100
|
serve Run stratusd, the always-on gateway: durable sessions, the
|
|
98
101
|
whole roster live at once (each agent on its own provider),
|
|
99
102
|
delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
|
|
100
|
-
(--idle-timeout <seconds>, --
|
|
101
|
-
--config <path>); everything it
|
|
102
|
-
~/.stratus/logs, which
|
|
103
|
+
(--idle-timeout <seconds>, --approvals <headless|remote>,
|
|
104
|
+
--no-events, --no-log-file, --config <path>); everything it
|
|
105
|
+
says is also written to ~/.stratus/logs, which
|
|
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)
|
|
103
110
|
service Keep stratusd running under launchd (macOS) or systemd
|
|
104
111
|
(Linux): install, uninstall, status, start, stop.
|
|
105
112
|
Installing starts it now and at every login
|
|
@@ -110,11 +117,16 @@ Commands:
|
|
|
110
117
|
--format json for the raw records
|
|
111
118
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
112
119
|
agents List your agents: who they are, where their souls live, what
|
|
113
|
-
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)
|
|
114
124
|
doctor Show what a run would use right now — provider, model, soul —
|
|
115
125
|
and which file or environment variable decided each, then
|
|
116
126
|
flag anything that would surprise you (--format json)
|
|
117
|
-
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
|
|
118
130
|
help Show this help message
|
|
119
131
|
|
|
120
132
|
Agent options:
|
|
@@ -132,11 +144,19 @@ Options:
|
|
|
132
144
|
--config Config file path (run: load settings from it, setup: write it)
|
|
133
145
|
--format Output format: text or json (default: text)
|
|
134
146
|
--no-events Hide event-by-event progress lines in text mode
|
|
135
|
-
--approvals
|
|
147
|
+
--approvals run/chat: tool approval mode — always, ask, or never (default: always)
|
|
148
|
+
serve: how the daemon reaches a human — headless (refuse every
|
|
149
|
+
gated call) or remote (ask in Slack). Default headless, or
|
|
150
|
+
the config file's "approvals.mode"
|
|
136
151
|
--max-turns Maximum provider turns per run (default: 8)
|
|
137
|
-
--port
|
|
138
|
-
--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)
|
|
139
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)
|
|
140
160
|
--help, -h Show this help message
|
|
141
161
|
|
|
142
162
|
Config file:
|
|
@@ -145,9 +165,28 @@ Config file:
|
|
|
145
165
|
A "soul" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.
|
|
146
166
|
Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.
|
|
147
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
|
+
|
|
148
185
|
Soul files:
|
|
149
186
|
A soul file is markdown with frontmatter (name, provider, model, tools, credentials)
|
|
150
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.
|
|
151
190
|
`;
|
|
152
191
|
const writeLine = (stream, line = '') => {
|
|
153
192
|
stream.write(`${line}\n`);
|
|
@@ -181,7 +220,7 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
181
220
|
}
|
|
182
221
|
if (command === 'dashboard') {
|
|
183
222
|
let port;
|
|
184
|
-
let host
|
|
223
|
+
let host;
|
|
185
224
|
let openBrowser = true;
|
|
186
225
|
for (let index = 0; index < rest.length; index += 1) {
|
|
187
226
|
const token = rest[index];
|
|
@@ -211,7 +250,12 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
211
250
|
}
|
|
212
251
|
throw new Error(`Unknown option: ${token}`);
|
|
213
252
|
}
|
|
214
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
command: 'dashboard',
|
|
255
|
+
...(port !== undefined ? { port } : {}),
|
|
256
|
+
...(host !== undefined ? { host } : {}),
|
|
257
|
+
openBrowser,
|
|
258
|
+
};
|
|
215
259
|
}
|
|
216
260
|
if (command === 'serve') {
|
|
217
261
|
const parsed = { command: 'serve', events: true };
|
|
@@ -237,6 +281,15 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
237
281
|
index += 1;
|
|
238
282
|
continue;
|
|
239
283
|
}
|
|
284
|
+
if (token === '--approvals') {
|
|
285
|
+
const value = readOptionValue(rest, index, '--approvals');
|
|
286
|
+
if (value !== 'headless' && value !== 'remote') {
|
|
287
|
+
throw new Error(`Unsupported approvals mode: ${value}. Use headless or remote.`);
|
|
288
|
+
}
|
|
289
|
+
parsed.approvals = value;
|
|
290
|
+
index += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
240
293
|
if (token === '--no-events') {
|
|
241
294
|
parsed.events = false;
|
|
242
295
|
continue;
|
|
@@ -245,6 +298,24 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
245
298
|
parsed.logToFile = false;
|
|
246
299
|
continue;
|
|
247
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
|
+
}
|
|
248
319
|
throw new Error(`Unknown option: ${token}`);
|
|
249
320
|
}
|
|
250
321
|
return parsed;
|
|
@@ -313,15 +384,17 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
313
384
|
if (command === 'agents' || (command === 'agent' && rest[0] === 'list')) {
|
|
314
385
|
const agentsRest = command === 'agents' ? rest : rest.slice(1);
|
|
315
386
|
let format = 'text';
|
|
387
|
+
let gateway;
|
|
388
|
+
let token;
|
|
316
389
|
for (let index = 0; index < agentsRest.length; index += 1) {
|
|
317
|
-
const
|
|
318
|
-
if (!
|
|
390
|
+
const argument = agentsRest[index];
|
|
391
|
+
if (!argument) {
|
|
319
392
|
continue;
|
|
320
393
|
}
|
|
321
|
-
if (
|
|
394
|
+
if (argument === '--help' || argument === '-h') {
|
|
322
395
|
return { command: 'help' };
|
|
323
396
|
}
|
|
324
|
-
if (
|
|
397
|
+
if (argument === '--format') {
|
|
325
398
|
const value = readOptionValue(agentsRest, index, '--format');
|
|
326
399
|
if (value !== 'text' && value !== 'json') {
|
|
327
400
|
throw new Error(`Unsupported format: ${value}`);
|
|
@@ -330,9 +403,27 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
330
403
|
index += 1;
|
|
331
404
|
continue;
|
|
332
405
|
}
|
|
333
|
-
|
|
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.');
|
|
334
420
|
}
|
|
335
|
-
return {
|
|
421
|
+
return {
|
|
422
|
+
command: 'agents',
|
|
423
|
+
format,
|
|
424
|
+
...(gateway ? { gateway } : {}),
|
|
425
|
+
...(token ? { token } : {}),
|
|
426
|
+
};
|
|
336
427
|
}
|
|
337
428
|
if (command === 'doctor') {
|
|
338
429
|
let format = 'text';
|
|
@@ -656,6 +747,10 @@ export const formatEvent = (event) => {
|
|
|
656
747
|
return `• tool.completed ${event.result.toolName} ok=${String(event.result.ok)}`;
|
|
657
748
|
case 'tool.denied':
|
|
658
749
|
return `• tool.denied ${event.call.toolName}`;
|
|
750
|
+
case 'tool.approval-requested':
|
|
751
|
+
return `• tool.approval-requested ${event.call.toolName} (${event.risk}) for ${event.agentId}`;
|
|
752
|
+
case 'tool.approval-resolved':
|
|
753
|
+
return `• tool.approval-resolved ${event.answer} (${event.reason})${event.actor ? ` by ${event.actor}` : ''}`;
|
|
659
754
|
case 'session.completed':
|
|
660
755
|
return `• session.completed ${event.sessionId}`;
|
|
661
756
|
case 'session.failed':
|
|
@@ -668,6 +763,14 @@ export const formatEvent = (event) => {
|
|
|
668
763
|
* The fields worth keeping per event type. Tool inputs and message text
|
|
669
764
|
* are deliberately excluded: the log is a trace of what happened, not a
|
|
670
765
|
* second copy of the transcript.
|
|
766
|
+
*
|
|
767
|
+
* The approval pair is the one place the trace carries a person's id, and
|
|
768
|
+
* it earns it: `always` widens what an agent may do unattended for the rest
|
|
769
|
+
* of the session, and "who decided that" is unanswerable afterwards from
|
|
770
|
+
* anything else. The refusal path is warned about by `onDecision`, but an
|
|
771
|
+
* *approval* produces no warning at all — without these two records a
|
|
772
|
+
* granted permission leaves no trace whatsoever. Still no tool input: what
|
|
773
|
+
* was asked is here, what it was asked with is not.
|
|
671
774
|
*/
|
|
672
775
|
const eventDetail = (event) => {
|
|
673
776
|
switch (event.type) {
|
|
@@ -680,14 +783,30 @@ const eventDetail = (event) => {
|
|
|
680
783
|
return { tool: event.call.toolName };
|
|
681
784
|
case 'tool.completed':
|
|
682
785
|
return { tool: event.result.toolName, ok: event.result.ok };
|
|
786
|
+
case 'tool.approval-requested':
|
|
787
|
+
return { tool: event.call.toolName, risk: event.risk, requestId: event.requestId };
|
|
788
|
+
case 'tool.approval-resolved':
|
|
789
|
+
return {
|
|
790
|
+
requestId: event.requestId,
|
|
791
|
+
answer: event.answer,
|
|
792
|
+
reason: event.reason,
|
|
793
|
+
...(event.actor ? { actor: event.actor } : {}),
|
|
794
|
+
};
|
|
683
795
|
case 'session.failed':
|
|
684
796
|
return { error: event.error };
|
|
685
797
|
default:
|
|
686
798
|
return undefined;
|
|
687
799
|
}
|
|
688
800
|
};
|
|
689
|
-
|
|
690
|
-
|
|
801
|
+
/**
|
|
802
|
+
* Kept with its historical CLI signature: a parsed run command is a
|
|
803
|
+
* RuntimeSelection plus CLI-only fields the resolver ignores.
|
|
804
|
+
*
|
|
805
|
+
* Those fields are *permitted*, not required. Demanding a whole
|
|
806
|
+
* `ParsedRunCommand` made the type claim the resolver cared about
|
|
807
|
+
* `approvals`, `format`, and `events` — it reads none of them — so every
|
|
808
|
+
* caller with a selection in hand had to invent values to get past it.
|
|
809
|
+
*/
|
|
691
810
|
export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
|
|
692
811
|
/**
|
|
693
812
|
* A saved subscription sign-in silently demoted to per-token billing is the
|
|
@@ -797,6 +916,30 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
797
916
|
}
|
|
798
917
|
});
|
|
799
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
|
+
}
|
|
800
943
|
// The Claude Code runtime executes kernel tools by calling back into the
|
|
801
944
|
// runner built just below — late-bound because the runner needs the
|
|
802
945
|
// provider first.
|
|
@@ -831,16 +974,36 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
831
974
|
model: options.runtime.model,
|
|
832
975
|
...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
|
|
833
976
|
};
|
|
834
|
-
|
|
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 };
|
|
835
991
|
};
|
|
836
992
|
export const runSingleLoop = async (prompt, streams, options) => {
|
|
837
|
-
const { runner, agent, metadata } = await createAgentRuntime(streams, options);
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
+
}
|
|
844
1007
|
};
|
|
845
1008
|
export const printSessionSummary = (session, streams) => {
|
|
846
1009
|
writeLine(streams.stdout);
|
|
@@ -962,11 +1125,12 @@ export const runChat = async (command, streams, env = {}) => {
|
|
|
962
1125
|
pendingApproval = resolve;
|
|
963
1126
|
});
|
|
964
1127
|
};
|
|
965
|
-
const { runner, agent, metadata } = await createAgentRuntime(streams, {
|
|
1128
|
+
const { runner, agent, metadata, disposePlugins } = await createAgentRuntime(streams, {
|
|
966
1129
|
runtime,
|
|
967
1130
|
approvals: command.approvals,
|
|
968
1131
|
askApproval,
|
|
969
1132
|
...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
|
|
1133
|
+
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
970
1134
|
env,
|
|
971
1135
|
onEvent: (event) => {
|
|
972
1136
|
if (command.events) {
|
|
@@ -1068,6 +1232,8 @@ export const runChat = async (command, streams, env = {}) => {
|
|
|
1068
1232
|
}
|
|
1069
1233
|
}
|
|
1070
1234
|
rl.close();
|
|
1235
|
+
// A chat that held a browser open for an hour still has to put it down.
|
|
1236
|
+
await disposePlugins();
|
|
1071
1237
|
if (interactive) {
|
|
1072
1238
|
writeLine(streams.stdout, dim(`Bye — ${agent.name} keeps what they remembered.`));
|
|
1073
1239
|
}
|
|
@@ -1081,118 +1247,6 @@ const formatRuntimeBanner = (runtime) => {
|
|
|
1081
1247
|
const fallbackSuffix = runtime.fallback ? ` fallback=${runtime.fallback.model}` : '';
|
|
1082
1248
|
return `Starting Stratus Agent local loop with provider=${runtime.provider} model=${runtime.model}${fallbackSuffix}${soulSuffix}`;
|
|
1083
1249
|
};
|
|
1084
|
-
const escapeHtml = (value) => value
|
|
1085
|
-
.replaceAll('&', '&')
|
|
1086
|
-
.replaceAll('<', '<')
|
|
1087
|
-
.replaceAll('>', '>')
|
|
1088
|
-
.replaceAll('"', '"');
|
|
1089
|
-
const renderDashboardHtml = (url) => `<!doctype html>
|
|
1090
|
-
<html lang="en">
|
|
1091
|
-
<head>
|
|
1092
|
-
<meta charset="utf-8" />
|
|
1093
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1094
|
-
<title>${DASHBOARD_TITLE}</title>
|
|
1095
|
-
<style>
|
|
1096
|
-
:root { color-scheme: dark; }
|
|
1097
|
-
* { box-sizing: border-box; }
|
|
1098
|
-
body {
|
|
1099
|
-
margin: 0;
|
|
1100
|
-
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
1101
|
-
background: radial-gradient(circle at top, #1f3a5f 0%, #0b1020 45%, #05070f 100%);
|
|
1102
|
-
color: #f3f7ff;
|
|
1103
|
-
}
|
|
1104
|
-
main { max-width: 960px; margin: 0 auto; padding: 40px 20px 72px; }
|
|
1105
|
-
.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); }
|
|
1106
|
-
.hero { padding: 32px; margin-bottom: 20px; }
|
|
1107
|
-
.eyebrow { display: inline-block; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: #93c5fd; }
|
|
1108
|
-
h1 { font-size: clamp(32px, 6vw, 56px); line-height: 1; margin: 14px 0 12px; }
|
|
1109
|
-
p { color: #cbd5e1; line-height: 1.6; }
|
|
1110
|
-
.grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
|
1111
|
-
.card { padding: 24px; }
|
|
1112
|
-
.stat { font-size: 28px; font-weight: 700; margin: 6px 0; }
|
|
1113
|
-
code, pre, textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
1114
|
-
textarea {
|
|
1115
|
-
width: 100%; min-height: 120px; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.28);
|
|
1116
|
-
background: rgba(15, 23, 42, 0.9); color: #e2e8f0; padding: 14px; resize: vertical;
|
|
1117
|
-
}
|
|
1118
|
-
button {
|
|
1119
|
-
margin-top: 12px; border: 0; border-radius: 999px; padding: 12px 18px; font-weight: 700; cursor: pointer;
|
|
1120
|
-
background: linear-gradient(135deg, #60a5fa, #22d3ee); color: #08111f;
|
|
1121
|
-
}
|
|
1122
|
-
pre {
|
|
1123
|
-
margin: 14px 0 0; padding: 14px; border-radius: 14px; overflow: auto;
|
|
1124
|
-
background: rgba(2, 6, 23, 0.9); border: 1px solid rgba(148, 163, 184, 0.2); color: #bfdbfe;
|
|
1125
|
-
}
|
|
1126
|
-
a { color: #7dd3fc; }
|
|
1127
|
-
</style>
|
|
1128
|
-
</head>
|
|
1129
|
-
<body>
|
|
1130
|
-
<main>
|
|
1131
|
-
<section class="hero">
|
|
1132
|
-
<span class="eyebrow">Stratus Agent local dashboard</span>
|
|
1133
|
-
<h1>Stratus Agent is up.</h1>
|
|
1134
|
-
<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>
|
|
1135
|
-
<p><strong>Local URL:</strong> <a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>
|
|
1136
|
-
</section>
|
|
1137
|
-
<section class="grid">
|
|
1138
|
-
<article class="card">
|
|
1139
|
-
<div class="eyebrow">Status</div>
|
|
1140
|
-
<div class="stat">Ready</div>
|
|
1141
|
-
<p>The local server is running with Node standard library primitives only.</p>
|
|
1142
|
-
</article>
|
|
1143
|
-
<article class="card">
|
|
1144
|
-
<div class="eyebrow">About</div>
|
|
1145
|
-
<div class="stat">Minimal by design</div>
|
|
1146
|
-
<p>Use <code>stratus run</code> for the agent loop, or use the tester here to verify browser-to-local requests during development.</p>
|
|
1147
|
-
</article>
|
|
1148
|
-
</section>
|
|
1149
|
-
<section class="card" style="margin-top:20px;">
|
|
1150
|
-
<div class="eyebrow">Actionable test</div>
|
|
1151
|
-
<h2 style="margin:12px 0 8px;">Echo tester</h2>
|
|
1152
|
-
<p>Send a payload to the local dashboard API and inspect the response.</p>
|
|
1153
|
-
<textarea id="payload">Hello from Stratus Agent dashboard</textarea>
|
|
1154
|
-
<button id="send">POST /api/echo</button>
|
|
1155
|
-
<pre id="result">Waiting for input…</pre>
|
|
1156
|
-
</section>
|
|
1157
|
-
</main>
|
|
1158
|
-
<script>
|
|
1159
|
-
const button = document.getElementById('send');
|
|
1160
|
-
const payload = document.getElementById('payload');
|
|
1161
|
-
const result = document.getElementById('result');
|
|
1162
|
-
button.addEventListener('click', async () => {
|
|
1163
|
-
result.textContent = 'Sending...';
|
|
1164
|
-
try {
|
|
1165
|
-
const response = await fetch('/api/echo', {
|
|
1166
|
-
method: 'POST',
|
|
1167
|
-
headers: { 'content-type': 'application/json' },
|
|
1168
|
-
body: JSON.stringify({ text: payload.value })
|
|
1169
|
-
});
|
|
1170
|
-
const data = await response.json();
|
|
1171
|
-
result.textContent = JSON.stringify(data, null, 2);
|
|
1172
|
-
} catch (error) {
|
|
1173
|
-
result.textContent = String(error);
|
|
1174
|
-
}
|
|
1175
|
-
});
|
|
1176
|
-
</script>
|
|
1177
|
-
</body>
|
|
1178
|
-
</html>`;
|
|
1179
|
-
const readBody = async (request) => {
|
|
1180
|
-
let data = '';
|
|
1181
|
-
for await (const chunk of request) {
|
|
1182
|
-
data += chunk.toString();
|
|
1183
|
-
}
|
|
1184
|
-
return data;
|
|
1185
|
-
};
|
|
1186
|
-
const sendJson = (response, statusCode, payload) => {
|
|
1187
|
-
response.statusCode = statusCode;
|
|
1188
|
-
response.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1189
|
-
response.end(JSON.stringify(payload, null, 2));
|
|
1190
|
-
};
|
|
1191
|
-
const sendHtml = (response, html) => {
|
|
1192
|
-
response.statusCode = 200;
|
|
1193
|
-
response.setHeader('content-type', 'text/html; charset=utf-8');
|
|
1194
|
-
response.end(html);
|
|
1195
|
-
};
|
|
1196
1250
|
export const openExternalUrl = async (url) => {
|
|
1197
1251
|
const platform = process.platform;
|
|
1198
1252
|
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
@@ -1206,74 +1260,6 @@ export const openExternalUrl = async (url) => {
|
|
|
1206
1260
|
});
|
|
1207
1261
|
});
|
|
1208
1262
|
};
|
|
1209
|
-
export const startDashboardServer = async (options) => {
|
|
1210
|
-
const server = createServer(async (request, response) => {
|
|
1211
|
-
const method = request.method ?? 'GET';
|
|
1212
|
-
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
|
1213
|
-
if (method === 'GET' && requestUrl.pathname === '/') {
|
|
1214
|
-
const address = server.address();
|
|
1215
|
-
const port = typeof address === 'object' && address ? address.port : options.port ?? 0;
|
|
1216
|
-
const host = typeof address === 'object' && address && address.address ? address.address : options.host;
|
|
1217
|
-
sendHtml(response, renderDashboardHtml(`http://${host === '::' ? '127.0.0.1' : host}:${port}`));
|
|
1218
|
-
return;
|
|
1219
|
-
}
|
|
1220
|
-
if (method === 'GET' && requestUrl.pathname === '/api/status') {
|
|
1221
|
-
sendJson(response, 200, {
|
|
1222
|
-
ok: true,
|
|
1223
|
-
service: 'stratus-dashboard',
|
|
1224
|
-
version: CLI_VERSION,
|
|
1225
|
-
now: new Date().toISOString(),
|
|
1226
|
-
});
|
|
1227
|
-
return;
|
|
1228
|
-
}
|
|
1229
|
-
if (method === 'POST' && requestUrl.pathname === '/api/echo') {
|
|
1230
|
-
const raw = await readBody(request);
|
|
1231
|
-
let text = '';
|
|
1232
|
-
if (raw.trim().length > 0) {
|
|
1233
|
-
const payload = JSON.parse(raw);
|
|
1234
|
-
text = typeof payload.text === 'string' ? payload.text : '';
|
|
1235
|
-
}
|
|
1236
|
-
sendJson(response, 200, {
|
|
1237
|
-
ok: true,
|
|
1238
|
-
received: text,
|
|
1239
|
-
uppercase: text.toUpperCase(),
|
|
1240
|
-
length: text.length,
|
|
1241
|
-
});
|
|
1242
|
-
return;
|
|
1243
|
-
}
|
|
1244
|
-
sendJson(response, 404, { ok: false, error: 'Not found' });
|
|
1245
|
-
});
|
|
1246
|
-
await new Promise((resolve, reject) => {
|
|
1247
|
-
server.once('error', reject);
|
|
1248
|
-
server.listen(options.port ?? 0, options.host, () => {
|
|
1249
|
-
server.off('error', reject);
|
|
1250
|
-
resolve();
|
|
1251
|
-
});
|
|
1252
|
-
});
|
|
1253
|
-
const address = server.address();
|
|
1254
|
-
if (!address || typeof address === 'string') {
|
|
1255
|
-
throw new Error('Unable to determine dashboard server address.');
|
|
1256
|
-
}
|
|
1257
|
-
const host = address.address === '::' ? '127.0.0.1' : address.address;
|
|
1258
|
-
const url = `http://${host}:${address.port}`;
|
|
1259
|
-
return {
|
|
1260
|
-
url,
|
|
1261
|
-
close: async () => {
|
|
1262
|
-
if (!server.listening) {
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
await new Promise((resolve, reject) => {
|
|
1266
|
-
server.close((error) => {
|
|
1267
|
-
if (error) {
|
|
1268
|
-
reject(error);
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
resolve();
|
|
1272
|
-
});
|
|
1273
|
-
});
|
|
1274
|
-
},
|
|
1275
|
-
};
|
|
1276
|
-
};
|
|
1277
1263
|
// Printed commands must survive copy-paste into a shell, so anything outside
|
|
1278
1264
|
// the safe character set gets single-quoted.
|
|
1279
1265
|
const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
@@ -1581,34 +1567,6 @@ const createSetupPrompter = (streams, env, view) => {
|
|
|
1581
1567
|
},
|
|
1582
1568
|
};
|
|
1583
1569
|
};
|
|
1584
|
-
// Live check that a pasted key actually works, so the user finds out inside
|
|
1585
|
-
// setup instead of on their first run.
|
|
1586
|
-
const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
|
|
1587
|
-
if (typeof fetchImpl !== 'function') {
|
|
1588
|
-
return { status: 'unreachable', detail: 'fetch is unavailable' };
|
|
1589
|
-
}
|
|
1590
|
-
const root = (baseUrl ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL)).replace(/\/+$/, '');
|
|
1591
|
-
const url = provider === 'anthropic' ? `${root}/v1/models` : `${root}/models`;
|
|
1592
|
-
const headers = provider === 'anthropic'
|
|
1593
|
-
? { 'x-api-key': key, 'anthropic-version': '2023-06-01' }
|
|
1594
|
-
: { authorization: `Bearer ${key}` };
|
|
1595
|
-
try {
|
|
1596
|
-
const response = await fetchImpl(url, { headers });
|
|
1597
|
-
if (response.ok) {
|
|
1598
|
-
return { status: 'ok' };
|
|
1599
|
-
}
|
|
1600
|
-
// Only an explicit auth failure condemns the key. Compatible endpoints
|
|
1601
|
-
// (local models, proxies) often lack GET /models entirely — a 404/405
|
|
1602
|
-
// there says nothing about the key, so it stays saveable.
|
|
1603
|
-
if (response.status === 401 || response.status === 403) {
|
|
1604
|
-
return { status: 'rejected', detail: `HTTP ${response.status}` };
|
|
1605
|
-
}
|
|
1606
|
-
return { status: 'unreachable', detail: `the endpoint did not support a key check (HTTP ${response.status})` };
|
|
1607
|
-
}
|
|
1608
|
-
catch (error) {
|
|
1609
|
-
return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
|
|
1610
|
-
}
|
|
1611
|
-
};
|
|
1612
1570
|
// Slack verification talks to the Web API over plain fetch rather than
|
|
1613
1571
|
// through @stratusagent/channel-slack. Setup must stay usable before that
|
|
1614
1572
|
// optional package is installed, and the CLI deliberately does not depend
|
|
@@ -1691,7 +1649,12 @@ export const slackAppManifest = (agentName) => JSON.stringify({
|
|
|
1691
1649
|
oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
|
|
1692
1650
|
settings: {
|
|
1693
1651
|
event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
|
|
1694
|
-
|
|
1652
|
+
// Required for remote approval: Allow / Always allow / Deny arrive as
|
|
1653
|
+
// block_actions, and Slack delivers none of them to an app that has
|
|
1654
|
+
// interactivity switched off. Enabled unconditionally rather than per
|
|
1655
|
+
// mode — an app is created once, and discovering months later that the
|
|
1656
|
+
// buttons do nothing means editing the manifest and reinstalling.
|
|
1657
|
+
interactivity: { is_enabled: true },
|
|
1695
1658
|
org_deploy_enabled: false,
|
|
1696
1659
|
socket_mode_enabled: true,
|
|
1697
1660
|
token_rotation_enabled: false,
|
|
@@ -1702,18 +1665,6 @@ const DEFAULT_SOUL_STARTER = [
|
|
|
1702
1665
|
'keep replies short unless the question genuinely needs depth. Use',
|
|
1703
1666
|
'memory.remember for durable facts about the people you work with.',
|
|
1704
1667
|
].join('\n');
|
|
1705
|
-
// Shown when live model listing is unavailable (e.g. subscription tokens
|
|
1706
|
-
// cannot call the models endpoint, or the machine is offline).
|
|
1707
|
-
// Model ids that cannot serve /chat/completions and must not become the
|
|
1708
|
-
// default: embeddings, audio, images, moderation, and legacy completions.
|
|
1709
|
-
const NON_CHAT_MODEL_PATTERN = /embed|whisper|tts|audio|dall-e|image|moderation|realtime|transcribe|davinci|babbage|curie|(^|[-_])ada([-_]|$)/i;
|
|
1710
|
-
const KNOWN_CLAUDE_MODELS = [
|
|
1711
|
-
'claude-opus-5',
|
|
1712
|
-
'claude-sonnet-5',
|
|
1713
|
-
'claude-haiku-4-5',
|
|
1714
|
-
'claude-opus-4-6',
|
|
1715
|
-
'claude-sonnet-4-6',
|
|
1716
|
-
];
|
|
1717
1668
|
export const runSetup = async (command, streams, env = {}) => {
|
|
1718
1669
|
const cwd = readWorkingDirectory(env);
|
|
1719
1670
|
const processEnv = readProcessEnv(env);
|
|
@@ -1816,7 +1767,9 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1816
1767
|
consumeNotices: () => recentNotices.splice(0),
|
|
1817
1768
|
});
|
|
1818
1769
|
const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
|
|
1819
|
-
|
|
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');
|
|
1820
1773
|
const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
|
|
1821
1774
|
const providerSignInStatus = (provider) => {
|
|
1822
1775
|
const credential = state.credentials[provider];
|
|
@@ -2078,85 +2031,16 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2078
2031
|
await maybeSwitchDefault('anthropic');
|
|
2079
2032
|
}
|
|
2080
2033
|
};
|
|
2081
|
-
// Every model the current sign-ins can actually reach
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
// never the default provider's secret.
|
|
2092
|
-
const envKey = (provider === state.provider
|
|
2093
|
-
? readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
2094
|
-
?? (state.apiKeyEnv ? readNonEmptyString(processEnv[state.apiKeyEnv]) : undefined)
|
|
2095
|
-
: undefined)
|
|
2096
|
-
?? readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]);
|
|
2097
|
-
const credential = envKey ? undefined : state.credentials[provider];
|
|
2098
|
-
const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
|
|
2099
|
-
if (!apiKey && !credential) {
|
|
2100
|
-
continue;
|
|
2101
|
-
}
|
|
2102
|
-
if (provider === 'anthropic') {
|
|
2103
|
-
if (!apiKey || typeof fetchImpl !== 'function') {
|
|
2104
|
-
// Subscription tokens cannot call the models endpoint.
|
|
2105
|
-
models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
|
|
2106
|
-
continue;
|
|
2107
|
-
}
|
|
2108
|
-
// The same endpoint a real run uses: the stored key's bound URL is
|
|
2109
|
-
// authoritative, then a configured anthropic base URL (a proxy) —
|
|
2110
|
-
// never the official endpoint by accident.
|
|
2111
|
-
const anthropicRoot = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
2112
|
-
?? (state.provider === 'anthropic' ? state.baseUrl : undefined)
|
|
2113
|
-
?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, '');
|
|
2114
|
-
try {
|
|
2115
|
-
const response = await fetchImpl(`${anthropicRoot}/v1/models?limit=100`, {
|
|
2116
|
-
headers: { 'x-api-key': String(apiKey), 'anthropic-version': '2023-06-01' },
|
|
2117
|
-
});
|
|
2118
|
-
const payload = await response.json();
|
|
2119
|
-
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string');
|
|
2120
|
-
models.push(...(ids.length > 0 ? ids : KNOWN_CLAUDE_MODELS).map((id) => ({ provider, id })));
|
|
2121
|
-
}
|
|
2122
|
-
catch {
|
|
2123
|
-
models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
|
|
2124
|
-
}
|
|
2125
|
-
continue;
|
|
2126
|
-
}
|
|
2127
|
-
if (!apiKey || typeof fetchImpl !== 'function') {
|
|
2128
|
-
continue;
|
|
2129
|
-
}
|
|
2130
|
-
try {
|
|
2131
|
-
// A stored key's bound endpoint is authoritative, exactly as at run
|
|
2132
|
-
// time; only env-supplied keys follow the default provider's URL.
|
|
2133
|
-
const root = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
2134
|
-
?? (state.provider === 'openai' ? state.baseUrl : undefined)
|
|
2135
|
-
?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
|
|
2136
|
-
const response = await fetchImpl(`${root}/models`, {
|
|
2137
|
-
headers: { authorization: `Bearer ${String(apiKey)}` },
|
|
2138
|
-
});
|
|
2139
|
-
const payload = await response.json();
|
|
2140
|
-
const allIds = (payload.data ?? [])
|
|
2141
|
-
.map((entry) => entry.id)
|
|
2142
|
-
.filter((id) => typeof id === 'string');
|
|
2143
|
-
// Runs always call /chat/completions, so embedding, audio, image,
|
|
2144
|
-
// moderation, and legacy completion models would save a default
|
|
2145
|
-
// that cannot execute. If filtering leaves nothing (an exotic local
|
|
2146
|
-
// service), show everything rather than an empty menu.
|
|
2147
|
-
const chatIds = allIds.filter((id) => !NON_CHAT_MODEL_PATTERN.test(id));
|
|
2148
|
-
const ids = (chatIds.length > 0 ? chatIds : allIds).sort((a, b) => {
|
|
2149
|
-
const rank = (id) => (/^gpt/i.test(id) ? 0 : /^o\d/i.test(id) ? 1 : 2);
|
|
2150
|
-
return rank(a) - rank(b) || a.localeCompare(b);
|
|
2151
|
-
});
|
|
2152
|
-
models.push(...ids.map((id) => ({ provider, id })));
|
|
2153
|
-
}
|
|
2154
|
-
catch {
|
|
2155
|
-
// No reachable model list for this provider; skip it.
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
return models;
|
|
2159
|
-
};
|
|
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);
|
|
2160
2044
|
const pickModel = async (kind) => {
|
|
2161
2045
|
const available = await collectAvailableModels();
|
|
2162
2046
|
if (available.length === 0) {
|
|
@@ -2298,17 +2182,12 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2298
2182
|
}
|
|
2299
2183
|
const name = await prompter.ask('Name your agent (Enter to have one generated): ');
|
|
2300
2184
|
const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
|
|
2301
|
-
const
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
const
|
|
2306
|
-
|
|
2307
|
-
...(state.provider !== 'demo' ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) } : {}),
|
|
2308
|
-
});
|
|
2309
|
-
const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
2310
|
-
await mkdir(path.dirname(soulPath), { recursive: true });
|
|
2311
|
-
await writeFile(soulPath, soul);
|
|
2185
|
+
const persona = instructions || DEFAULT_SOUL_STARTER;
|
|
2186
|
+
const pin = state.provider !== 'demo'
|
|
2187
|
+
? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) }
|
|
2188
|
+
: {};
|
|
2189
|
+
const claimed = await claimSoulFile(env, { ...(name ? { name } : {}), instructions: persona }, (agent) => formatSoul({ agent, ...pin }), (message) => writeLine(streams.stdout, message));
|
|
2190
|
+
const { agent, soulPath } = claimed;
|
|
2312
2191
|
state.soulPath = soulPath;
|
|
2313
2192
|
writeLine(streams.stdout, `Say hello to ${agent.name}.`);
|
|
2314
2193
|
writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
|
|
@@ -2500,19 +2379,31 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2500
2379
|
// built-in's place — the gateway skips it, so offering it here would
|
|
2501
2380
|
// name an app after an agent that never receives the messages.
|
|
2502
2381
|
const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
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);
|
|
2382
|
+
// loadRosterSouls refuses a roster whose files collide, and drops the
|
|
2383
|
+
// ones claiming the reserved built-in id, so what comes back here is
|
|
2384
|
+
// already unambiguous.
|
|
2385
|
+
let rosterSouls = [];
|
|
2386
|
+
let rosterLoaded = true;
|
|
2387
|
+
try {
|
|
2388
|
+
rosterSouls = await loadRosterSouls(env, warnOnce);
|
|
2515
2389
|
}
|
|
2390
|
+
catch (error) {
|
|
2391
|
+
// A roster that cannot say who its agents are cannot have channels
|
|
2392
|
+
// configured for them — but the rest of setup (providers, models,
|
|
2393
|
+
// sign-ins) still works, so this reports and moves on rather than
|
|
2394
|
+
// taking the whole command down.
|
|
2395
|
+
rosterLoaded = false;
|
|
2396
|
+
warnOnce(error instanceof Error ? error.message : String(error));
|
|
2397
|
+
// Nothing is offered, not merely the colliding pair. A roster that
|
|
2398
|
+
// refuses to load fails `createGateway.start()` outright, so no
|
|
2399
|
+
// agent is servable — not the built-in seeded above, and not the
|
|
2400
|
+
// configured soul resolved below (which can itself BE one of the
|
|
2401
|
+
// colliding files). Listing any of them invites connecting a Slack
|
|
2402
|
+
// app to an agent the daemon cannot bring online, and connecting an
|
|
2403
|
+
// app is the expensive half of that mistake.
|
|
2404
|
+
return { entries: [], loaded: false };
|
|
2405
|
+
}
|
|
2406
|
+
entries.push(...rosterSouls);
|
|
2516
2407
|
// The soul a run resolves to, in resolveSoulPath's own order: an env
|
|
2517
2408
|
// override outranks the config value this setup session is editing.
|
|
2518
2409
|
// Listing the config soul while `stratus serve` registers the env one
|
|
@@ -2525,11 +2416,11 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2525
2416
|
}
|
|
2526
2417
|
const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
|
|
2527
2418
|
if (!effectiveSoul) {
|
|
2528
|
-
return entries;
|
|
2419
|
+
return { entries, loaded: rosterLoaded };
|
|
2529
2420
|
}
|
|
2530
2421
|
const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
|
|
2531
2422
|
if (entries.some((entry) => entry.path === resolved)) {
|
|
2532
|
-
return entries;
|
|
2423
|
+
return { entries, loaded: rosterLoaded };
|
|
2533
2424
|
}
|
|
2534
2425
|
try {
|
|
2535
2426
|
const soul = await loadSoulFile(resolved);
|
|
@@ -2548,7 +2439,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2548
2439
|
catch (error) {
|
|
2549
2440
|
warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
|
|
2550
2441
|
}
|
|
2551
|
-
return entries;
|
|
2442
|
+
return { entries, loaded: rosterLoaded };
|
|
2552
2443
|
};
|
|
2553
2444
|
const serviceSummary = () => {
|
|
2554
2445
|
if (!servicePlatform(serviceEnvFor(env))) {
|
|
@@ -2594,7 +2485,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2594
2485
|
};
|
|
2595
2486
|
const chooseChannels = async () => {
|
|
2596
2487
|
while (true) {
|
|
2597
|
-
const roster = await channelRoster();
|
|
2488
|
+
const { entries: roster, loaded: rosterLoaded } = await channelRoster();
|
|
2598
2489
|
const slack = state.channels.slack ?? {};
|
|
2599
2490
|
// The roster always holds at least the built-in agent, so there is
|
|
2600
2491
|
// no empty state to short-circuit on — and short-circuiting would
|
|
@@ -2606,7 +2497,19 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2606
2497
|
});
|
|
2607
2498
|
// Orphans: tokens whose agent left the roster would otherwise be
|
|
2608
2499
|
// invisible here while still being loaded by `stratus serve`.
|
|
2609
|
-
|
|
2500
|
+
//
|
|
2501
|
+
// Only when the roster actually loaded. "No agent has this id" is a
|
|
2502
|
+
// claim about the roster, and a roster that refused to load cannot
|
|
2503
|
+
// support it — every stored token would look orphaned, and this list
|
|
2504
|
+
// offers to DELETE them. Losing a working agent's Slack credentials
|
|
2505
|
+
// because a different pair of files collided is a far worse outcome
|
|
2506
|
+
// than leaving a real orphan on screen for one run.
|
|
2507
|
+
const orphans = rosterLoaded
|
|
2508
|
+
? Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id))
|
|
2509
|
+
: [];
|
|
2510
|
+
if (!rosterLoaded) {
|
|
2511
|
+
writeLine(streams.stderr, 'Warning: no agents are offered while the roster is unreadable — the daemon cannot start either. Fix the roster first.');
|
|
2512
|
+
}
|
|
2610
2513
|
for (const id of orphans) {
|
|
2611
2514
|
options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
|
|
2612
2515
|
}
|
|
@@ -2740,8 +2643,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2740
2643
|
config.fallbackBaseUrl = state.fallbackBaseUrl;
|
|
2741
2644
|
}
|
|
2742
2645
|
}
|
|
2743
|
-
await
|
|
2744
|
-
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
2646
|
+
await saveConfigFile(configPath, config);
|
|
2745
2647
|
writeLine(streams.stdout);
|
|
2746
2648
|
writeLine(streams.stdout, `Wrote ${configPath}`);
|
|
2747
2649
|
// A project-local config in this directory outranks the global file for
|
|
@@ -2924,127 +2826,93 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2924
2826
|
prompter.close();
|
|
2925
2827
|
}
|
|
2926
2828
|
};
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
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);
|
|
2931
2841
|
}
|
|
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
2842
|
try {
|
|
2999
|
-
|
|
3000
|
-
.filter((file) => file.endsWith('.md'))
|
|
3001
|
-
.sort()
|
|
3002
|
-
.map((file) => path.join(agentsDirPath(env), file));
|
|
2843
|
+
return (await readFile(gatewayTokenPath(env), 'utf8')).trim();
|
|
3003
2844
|
}
|
|
3004
2845
|
catch (error) {
|
|
3005
2846
|
if (error.code !== 'ENOENT') {
|
|
3006
2847
|
throw error;
|
|
3007
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.');
|
|
3008
2851
|
}
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
}
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
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(' ')}`;
|
|
3037
2901
|
if (command.format === 'json') {
|
|
3038
2902
|
writeLine(streams.stdout, JSON.stringify({
|
|
3039
|
-
agents: listings.map(({ isDefault, ...rest }) => ({
|
|
2903
|
+
agents: listings.map(({ default: isDefault, avatar, ...rest }) => ({
|
|
2904
|
+
...rest,
|
|
2905
|
+
...(avatar ? { avatar: describeAvatar(avatar) } : {}),
|
|
2906
|
+
default: isDefault,
|
|
2907
|
+
})),
|
|
3040
2908
|
}, null, 2));
|
|
3041
2909
|
return 0;
|
|
3042
2910
|
}
|
|
3043
2911
|
const describeRunsOn = (runsOn) => (runsOn.provider === 'demo' ? 'demo (offline)' : `${runsOn.provider}${runsOn.model ? ` · ${runsOn.model}` : ''}`);
|
|
3044
|
-
writeLine(streams.stdout, 'Agents');
|
|
2912
|
+
writeLine(streams.stdout, command.gateway ? `Agents on ${command.gateway}` : 'Agents');
|
|
3045
2913
|
for (const agent of listings) {
|
|
3046
2914
|
const labels = [
|
|
3047
|
-
...(agent.
|
|
2915
|
+
...(agent.default ? ['default'] : []),
|
|
3048
2916
|
...(agent.builtIn ? ['built-in'] : []),
|
|
3049
2917
|
];
|
|
3050
2918
|
writeLine(streams.stdout);
|
|
@@ -3059,7 +2927,7 @@ export const runAgents = async (command, streams, env = {}) => {
|
|
|
3059
2927
|
writeLine(streams.stdout, ` persona ${agent.persona}`);
|
|
3060
2928
|
}
|
|
3061
2929
|
if (agent.avatar) {
|
|
3062
|
-
writeLine(streams.stdout, ` avatar ${agent.avatar}`);
|
|
2930
|
+
writeLine(streams.stdout, ` avatar ${describeAvatar(agent.avatar)}`);
|
|
3063
2931
|
}
|
|
3064
2932
|
}
|
|
3065
2933
|
writeLine(streams.stdout);
|
|
@@ -3306,12 +3174,28 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
3306
3174
|
}
|
|
3307
3175
|
// Tokens keyed to an agent the roster no longer has are loaded and
|
|
3308
3176
|
// skipped on every start, silently.
|
|
3309
|
-
|
|
3177
|
+
let rosterEntries = [];
|
|
3178
|
+
let rosterLoaded = true;
|
|
3179
|
+
try {
|
|
3180
|
+
rosterEntries = await loadRosterSouls(env, warn);
|
|
3181
|
+
}
|
|
3182
|
+
catch (error) {
|
|
3183
|
+
// Doctor exists to name problems, so a roster it cannot load is a
|
|
3184
|
+
// finding to report — not an exception that replaces the report.
|
|
3185
|
+
rosterLoaded = false;
|
|
3186
|
+
problems.push(error instanceof Error ? error.message : String(error));
|
|
3187
|
+
}
|
|
3188
|
+
const rosterIds = new Set(rosterEntries.map((entry) => entry.soul.agent.id));
|
|
3310
3189
|
rosterIds.add(DEFAULT_STRATUS_AGENT.id);
|
|
3311
3190
|
if (soul) {
|
|
3312
3191
|
rosterIds.add(soul.agent.id);
|
|
3313
3192
|
}
|
|
3314
|
-
|
|
3193
|
+
// Only against a roster that actually loaded. "No agent has this id" is
|
|
3194
|
+
// a claim about the roster, and a refused one cannot support it — every
|
|
3195
|
+
// stored token would be reported as orphaned, and this advises clearing
|
|
3196
|
+
// them. Advice is not deletion, but an operator who follows it loses the
|
|
3197
|
+
// same credentials by hand.
|
|
3198
|
+
const orphans = rosterLoaded ? slackAgents.filter((id) => !rosterIds.has(id)) : [];
|
|
3315
3199
|
if (orphans.length > 0) {
|
|
3316
3200
|
problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
|
|
3317
3201
|
+ 'Clear them from `stratus setup` → Channels.');
|
|
@@ -3462,34 +3346,66 @@ const serviceEnvFor = (env) => ({
|
|
|
3462
3346
|
* reported everything ready. Returns the variable to name, if so.
|
|
3463
3347
|
*/
|
|
3464
3348
|
/**
|
|
3465
|
-
*
|
|
3466
|
-
*
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3349
|
+
* The daemon's `approvals` block, from the config file the daemon itself
|
|
3350
|
+
* would load. Discovery goes through the shared resolver rather than
|
|
3351
|
+
* reading ~/.stratus/config.json directly: `--config` and STRATUS_CONFIG
|
|
3352
|
+
* both move it, and a second copy of that precedence would resolve the
|
|
3353
|
+
* approver set from a file the gateway is not running on.
|
|
3354
|
+
*
|
|
3355
|
+
* **Only from a trusted location.** An auto-discovered project-local
|
|
3356
|
+
* `stratus.config.json` outranks the global one and can be checked into any
|
|
3357
|
+
* repository — which is why stored credentials are already never combined
|
|
3358
|
+
* with an endpoint it selects. This block is the same kind of boundary and
|
|
3359
|
+
* a sharper one: it names the people who may authorize an agent's gated
|
|
3360
|
+
* tool calls, and it would do so through Slack tokens the user configured
|
|
3361
|
+
* globally. A cloned repo must not be able to appoint its own approver, so
|
|
3362
|
+
* an untrusted config's approvals are ignored — loudly, since silently
|
|
3363
|
+
* dropping the block someone is looking at is its own kind of wrong.
|
|
3364
|
+
*
|
|
3365
|
+
* An unreadable config degrades to headless with a warning, matching how
|
|
3366
|
+
* every other consumer treats one: refusing to start would take the whole
|
|
3367
|
+
* fleet down over a policy block that may not even be present.
|
|
3470
3368
|
*/
|
|
3471
|
-
const
|
|
3472
|
-
const
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
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 });
|
|
3369
|
+
const loadServeApprovals = async (env, configPath, warn) => {
|
|
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 {};
|
|
3482
3375
|
}
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3376
|
+
if (block.status === 'unreadable') {
|
|
3377
|
+
warn(`ignoring the approvals config (${block.error instanceof Error ? block.error.message : String(block.error)}); refusing gated calls`);
|
|
3378
|
+
return {};
|
|
3379
|
+
}
|
|
3380
|
+
return block.status === 'present' ? block.value : {};
|
|
3381
|
+
};
|
|
3382
|
+
/**
|
|
3383
|
+
* One line naming who can actually answer, so a remote daemon does not look
|
|
3384
|
+
* configured when it is not. Two ways it can be hollow, and they fail
|
|
3385
|
+
* differently, so they read differently:
|
|
3386
|
+
*
|
|
3387
|
+
* - No channel running at all (no Slack tokens, or the optional package is
|
|
3388
|
+
* not installed) — nothing renders the request, so the turn waits out the
|
|
3389
|
+
* whole timeout before being denied. That is the bad one, and the only
|
|
3390
|
+
* place it is visible is here, at startup.
|
|
3391
|
+
* - A channel is running but an agent has no approvers — the adapter denies
|
|
3392
|
+
* that agent's requests on arrival, which is at least prompt.
|
|
3393
|
+
*
|
|
3394
|
+
* `agentIds` is therefore the agents that can actually be *asked*, not
|
|
3395
|
+
* every agent with tokens on disk.
|
|
3396
|
+
*/
|
|
3397
|
+
const describeApprovers = (approvals, agentIds) => {
|
|
3398
|
+
if (agentIds.length === 0) {
|
|
3399
|
+
return 'but no channel is running to ask through, so gated calls will wait out the approval timeout and then be denied';
|
|
3400
|
+
}
|
|
3401
|
+
const covered = agentIds.filter((agentId) => (resolveAgentApprovals(approvals, agentId).slackApprovers ?? []).length > 0);
|
|
3402
|
+
if (covered.length === 0) {
|
|
3403
|
+
return 'but no approvers are configured, so every gated call is denied on arrival';
|
|
3491
3404
|
}
|
|
3492
|
-
|
|
3405
|
+
const uncovered = agentIds.filter((agentId) => !covered.includes(agentId));
|
|
3406
|
+
return uncovered.length === 0
|
|
3407
|
+
? `approvers set for ${covered.join(', ')}`
|
|
3408
|
+
: `approvers set for ${covered.join(', ')}; none for ${uncovered.join(', ')}, whose calls are denied on arrival`;
|
|
3493
3409
|
};
|
|
3494
3410
|
/**
|
|
3495
3411
|
* `stratus service` — run the daemon under launchd or systemd, so it
|
|
@@ -3604,7 +3520,6 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3604
3520
|
const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
|
|
3605
3521
|
const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
|
|
3606
3522
|
const persona = instructions || DEFAULT_SOUL_STARTER;
|
|
3607
|
-
let agent = defineAgent({ name, instructions: persona });
|
|
3608
3523
|
// Frontmatter pins what a run from this directory would actually use:
|
|
3609
3524
|
// env vars outrank the active config file (project-local or explicit
|
|
3610
3525
|
// first, global otherwise), and no provider anywhere falls back to
|
|
@@ -3629,27 +3544,8 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3629
3544
|
?? (configModelApplies ? activeConfig.model : undefined)
|
|
3630
3545
|
?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
|
|
3631
3546
|
const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
// repeat name would otherwise silently overwrite an earlier agent's
|
|
3635
|
-
// soul and share their memory. 'wx' makes each claim atomic; on a
|
|
3636
|
-
// collision the id gets a fresh suffix and we try again.
|
|
3637
|
-
await mkdir(agentsDirPath(env), { recursive: true });
|
|
3638
|
-
const baseId = agent.id;
|
|
3639
|
-
let soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
3640
|
-
for (;;) {
|
|
3641
|
-
try {
|
|
3642
|
-
await writeFile(soulPath, formatSoul({ agent, ...soulPin }), { flag: 'wx' });
|
|
3643
|
-
break;
|
|
3644
|
-
}
|
|
3645
|
-
catch (error) {
|
|
3646
|
-
if (error.code !== 'EEXIST') {
|
|
3647
|
-
throw error;
|
|
3648
|
-
}
|
|
3649
|
-
agent = defineAgent({ id: `${baseId}-${randomUUID().slice(0, 4)}`, name, instructions: persona });
|
|
3650
|
-
soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
3651
|
-
}
|
|
3652
|
-
}
|
|
3547
|
+
const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
|
|
3548
|
+
const { agent, soulPath } = claimed;
|
|
3653
3549
|
const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
|
|
3654
3550
|
'Yes — every stratus run talks to them',
|
|
3655
3551
|
'Not now',
|
|
@@ -3672,9 +3568,16 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3672
3568
|
}
|
|
3673
3569
|
}
|
|
3674
3570
|
if (globalConfig !== undefined) {
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
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);
|
|
3678
3581
|
madeDefault = true;
|
|
3679
3582
|
if (configLocation && configLocation.path !== globalConfigPath(env)) {
|
|
3680
3583
|
writeLine(streams.stdout, `Note: ${configLocation.path} takes precedence over the global config for runs started in this directory.`);
|
|
@@ -3723,30 +3626,166 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3723
3626
|
writeLine(streams.stdout, JSON.stringify(agent, null, 2));
|
|
3724
3627
|
return 0;
|
|
3725
3628
|
};
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
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}` },
|
|
3730
3652
|
});
|
|
3731
|
-
|
|
3732
|
-
|
|
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.');
|
|
3733
3766
|
if (command.openBrowser) {
|
|
3734
3767
|
try {
|
|
3735
|
-
await (env.openExternal ?? openExternalUrl)(
|
|
3768
|
+
await (env.openExternal ?? openExternalUrl)(url);
|
|
3736
3769
|
writeLine(streams.stdout, 'Opened your default browser.');
|
|
3737
3770
|
}
|
|
3738
3771
|
catch (error) {
|
|
3739
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}`);
|
|
3740
3774
|
}
|
|
3741
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.');
|
|
3742
3784
|
if (env.dashboardAutoShutdownMs !== undefined) {
|
|
3743
3785
|
await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
|
|
3744
|
-
|
|
3745
|
-
return 0;
|
|
3786
|
+
ownDaemon.abort();
|
|
3746
3787
|
}
|
|
3747
|
-
|
|
3748
|
-
await handle.close();
|
|
3749
|
-
return 0;
|
|
3788
|
+
return serving;
|
|
3750
3789
|
};
|
|
3751
3790
|
/**
|
|
3752
3791
|
* Loads the optional Slack channel package, or undefined when it is not
|
|
@@ -3773,6 +3812,65 @@ const loadSlackAdapter = async () => {
|
|
|
3773
3812
|
// installed package, and surfaces.
|
|
3774
3813
|
return (await import('@stratusagent/channel-slack')).createSlackChannelAdapter;
|
|
3775
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
|
+
};
|
|
3776
3874
|
/**
|
|
3777
3875
|
* `stratus serve` — run the gateway (stratusd) in the foreground: load the
|
|
3778
3876
|
* roster, accept dispatches, print events, and drain cleanly on SIGTERM,
|
|
@@ -3812,9 +3910,58 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3812
3910
|
// tokens are gateway infrastructure secrets in the channels namespace of
|
|
3813
3911
|
// ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
|
|
3814
3912
|
// for the 2-minute per-agent app setup).
|
|
3913
|
+
// The approval policy and the Slack approver sets come from the same
|
|
3914
|
+
// config block, resolved once here: the daemon must not answer "who can
|
|
3915
|
+
// approve this" differently from "is anyone being asked at all".
|
|
3916
|
+
const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
|
|
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
|
+
}
|
|
3815
3956
|
const channelCredentials = await loadChannelCredentials(env);
|
|
3816
3957
|
const slackAgents = Object.entries(channelCredentials.slack ?? {});
|
|
3817
|
-
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;
|
|
3818
3965
|
if (slackAgents.length > 0) {
|
|
3819
3966
|
// Channel packages are optional peers: the CLI never bundles a
|
|
3820
3967
|
// transport nobody asked for (the Slack SDKs alone are ~9 MB). A
|
|
@@ -3823,12 +3970,18 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3823
3970
|
// module-not-found stack.
|
|
3824
3971
|
const adapter = await loadSlackAdapter();
|
|
3825
3972
|
if (adapter) {
|
|
3973
|
+
slackAdapterUp = true;
|
|
3826
3974
|
channels.push(adapter({
|
|
3827
|
-
agents: slackAgents.map(([agentId, tokens]) =>
|
|
3828
|
-
agentId
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3975
|
+
agents: slackAgents.map(([agentId, tokens]) => {
|
|
3976
|
+
const route = resolveAgentApprovals(approvalsConfig, agentId);
|
|
3977
|
+
return {
|
|
3978
|
+
agentId,
|
|
3979
|
+
appToken: tokens.appToken,
|
|
3980
|
+
botToken: tokens.botToken,
|
|
3981
|
+
...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
|
|
3982
|
+
...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
|
|
3983
|
+
};
|
|
3984
|
+
}),
|
|
3832
3985
|
log,
|
|
3833
3986
|
warn,
|
|
3834
3987
|
}));
|
|
@@ -3876,23 +4029,63 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3876
4029
|
// while `serve` was something you ran in a terminal you were sitting at;
|
|
3877
4030
|
// it is not, now that setup installs it under launchd by default.
|
|
3878
4031
|
//
|
|
3879
|
-
//
|
|
3880
|
-
//
|
|
3881
|
-
//
|
|
3882
|
-
//
|
|
3883
|
-
//
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
4032
|
+
// There is no terminal behind a service manager, so the choice is
|
|
4033
|
+
// between refusing every gated call (`headless`) and parking the turn to
|
|
4034
|
+
// ask through a channel (`remote`). Headless stays the default: a daemon
|
|
4035
|
+
// that starts waiting on people who were never told they are on the hook
|
|
4036
|
+
// hangs turns instead of refusing them. Every refusal is logged — an
|
|
4037
|
+
// unattended denial that appears nowhere reads like an agent that decided
|
|
4038
|
+
// not to bother.
|
|
4039
|
+
const onDecision = (decision) => {
|
|
4040
|
+
if (decision.allowed) {
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
|
|
4044
|
+
};
|
|
4045
|
+
// A factory, not a policy: remote mode parks turns on a transport the
|
|
4046
|
+
// gateway owns, so the policy cannot exist until the gateway is building
|
|
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`);
|
|
3891
4059
|
},
|
|
3892
|
-
}
|
|
4060
|
+
};
|
|
4061
|
+
const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
|
|
4062
|
+
? { mode: 'remote', request: transport.request, onDecision, commands }
|
|
4063
|
+
: { mode: 'headless', onDecision, commands });
|
|
4064
|
+
if (approvalMode === 'remote') {
|
|
4065
|
+
// Only agents whose channel actually came up can be asked: tokens on
|
|
4066
|
+
// disk with the Slack package missing means nothing renders the
|
|
4067
|
+
// request, and the turn discovers that by hanging.
|
|
4068
|
+
const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
|
|
4069
|
+
log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
|
|
4070
|
+
}
|
|
3893
4071
|
const gateway = createGateway({
|
|
3894
4072
|
env,
|
|
3895
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
|
+
: {}),
|
|
4088
|
+
...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
|
|
3896
4089
|
...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
|
|
3897
4090
|
...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
|
|
3898
4091
|
...(channels.length > 0 ? { channels } : {}),
|
|
@@ -3959,6 +4152,21 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3959
4152
|
});
|
|
3960
4153
|
}
|
|
3961
4154
|
await gateway.start();
|
|
4155
|
+
// The roster is only known once the gateway has loaded it, and in remote
|
|
4156
|
+
// mode an agent no channel can ask for is the quietest failure this
|
|
4157
|
+
// feature has: its gated calls park with nobody rendering them and wait
|
|
4158
|
+
// out the whole timeout before being denied. No channel can detect this
|
|
4159
|
+
// on its own — a request is a broadcast, and no adapter knows whether
|
|
4160
|
+
// another one is about to answer — so it is reported here, where the
|
|
4161
|
+
// roster and the channel list are both in view.
|
|
4162
|
+
if (approvalMode === 'remote') {
|
|
4163
|
+
const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
|
|
4164
|
+
const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
|
|
4165
|
+
if (unreachable.length > 0) {
|
|
4166
|
+
warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
|
|
4167
|
+
+ 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
3962
4170
|
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
3963
4171
|
// And periodically, for a long-running daemon that warns steadily
|
|
3964
4172
|
// without ever writing enough records to rotate. Unref'd, so it never
|
|
@@ -4068,6 +4276,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
4068
4276
|
runtime,
|
|
4069
4277
|
approvals: command.approvals,
|
|
4070
4278
|
...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
|
|
4279
|
+
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
4071
4280
|
env: resolvedEnv,
|
|
4072
4281
|
});
|
|
4073
4282
|
if (command.format === 'json') {
|