@stratusagent/cli 0.4.0 → 0.6.0

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