@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/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
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
- // Kept in step with this package's own version by a test, because it is a
23
- // second copy of a number nobody re-reads: it is drawn in the setup header
24
- // and served from the dashboard's /api/status, so drift here misreports the
25
- // running build rather than failing anything.
26
- export const CLI_VERSION = '0.3.0';
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>, --no-events, --no-log-file,
101
- --config <path>); everything it says is also written to
102
- ~/.stratus/logs, which "stratus logs" reads
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 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
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 Tool approval mode: always, ask, or never (default: always)
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 Dashboard port, defaults to an open local port
138
- --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)
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 = DEFAULT_DASHBOARD_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 { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
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 token = agentsRest[index];
318
- if (!token) {
390
+ const argument = agentsRest[index];
391
+ if (!argument) {
319
392
  continue;
320
393
  }
321
- if (token === '--help' || token === '-h') {
394
+ if (argument === '--help' || argument === '-h') {
322
395
  return { command: 'help' };
323
396
  }
324
- if (token === '--format') {
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
- 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.');
334
420
  }
335
- return { command: 'agents', format };
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
- // Kept with its historical CLI signature: a parsed run command is a
690
- // RuntimeSelection plus CLI-only fields the resolver ignores.
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
- 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 };
835
991
  };
836
992
  export const runSingleLoop = async (prompt, streams, options) => {
837
- const { runner, agent, metadata } = await createAgentRuntime(streams, options);
838
- return runner.run({
839
- sessionId: randomUUID(),
840
- agent,
841
- userMessage: prompt,
842
- metadata,
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('&', '&amp;')
1086
- .replaceAll('<', '&lt;')
1087
- .replaceAll('>', '&gt;')
1088
- .replaceAll('"', '&quot;');
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
- interactivity: { is_enabled: false },
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
- 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');
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, fetched live where
2082
- // possible. Subscription tokens cannot call the models endpoint, so those
2083
- // fall back to the known Claude lineup.
2084
- const collectAvailableModels = async () => {
2085
- const fetchImpl = env.fetch ?? globalThis.fetch;
2086
- const models = [];
2087
- for (const provider of ['anthropic', 'openai']) {
2088
- // Discovery uses the credential a real run would use. STRATUS_API_KEY
2089
- // and a configured apiKeyEnv authenticate the DEFAULT provider only —
2090
- // a secondary provider relies on its own env var or stored sign-in,
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 agent = defineAgent({
2302
- ...(name ? { name } : {}),
2303
- instructions: instructions || DEFAULT_SOUL_STARTER,
2304
- });
2305
- const soul = formatSoul({
2306
- agent,
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
- // Mirror the gateway's loadRoster: two soul files declaring the same
2504
- // id are not two agents. Offering both would let someone create and
2505
- // name an app for the loser, which can never receive a message —
2506
- // Slack credentials are keyed by the shared id, and dispatch reaches
2507
- // the first sorted file.
2508
- for (const entry of await loadRosterSouls(env, warnOnce)) {
2509
- const clash = entries.find((seen) => seen.soul.agent.id === entry.soul.agent.id);
2510
- if (clash) {
2511
- warnOnce(`duplicate agent id ${entry.soul.agent.id} (${clash.path ?? 'built-in'} vs ${entry.path}); keeping the first`);
2512
- continue;
2513
- }
2514
- entries.push(entry);
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
- const orphans = Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id));
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 mkdir(path.dirname(configPath), { recursive: true });
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
- const personaSnippet = (instructions) => {
2928
- const firstLine = instructions?.split('\n').map((line) => line.trim()).find((line) => line.length > 0);
2929
- if (!firstLine) {
2930
- 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);
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
- rosterFiles = (await readdir(agentsDirPath(env)))
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
- for (const soulPath of rosterFiles) {
3010
- await addSoul(soulPath);
3011
- }
3012
- // A default soul can live outside ~/.stratus/agents (a project soul, a
3013
- // hand-written file) — the roster would be lying without it.
3014
- if (resolvedDefaultSoul && !rosterFiles.includes(resolvedDefaultSoul)) {
3015
- await addSoul(resolvedDefaultSoul);
3016
- }
3017
- // The built-in Stratus persona serves every run that has no soul.
3018
- const builtInPersona = personaSnippet(DEFAULT_STRATUS_AGENT.instructions);
3019
- listings.push({
3020
- id: DEFAULT_STRATUS_AGENT.id,
3021
- name: DEFAULT_STRATUS_AGENT.name,
3022
- isDefault: resolvedDefaultSoul === undefined,
3023
- builtIn: true,
3024
- runsOn: runsOnFor(),
3025
- memories: (await memory.list(DEFAULT_STRATUS_AGENT.id)).length,
3026
- ...(builtInPersona ? { persona: builtInPersona } : {}),
3027
- });
3028
- listings.sort((a, b) => {
3029
- if (a.isDefault !== b.isDefault) {
3030
- return a.isDefault ? -1 : 1;
3031
- }
3032
- if (a.builtIn !== b.builtIn) {
3033
- return a.builtIn ? 1 : -1;
3034
- }
3035
- return a.name.localeCompare(b.name);
3036
- });
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 }) => ({ ...rest, default: isDefault })),
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.isDefault ? ['default'] : []),
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
- const rosterIds = new Set((await loadRosterSouls(env, warn)).map((entry) => entry.soul.agent.id));
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
- const orphans = slackAgents.filter((id) => !rosterIds.has(id));
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
- * Every runtime the daemon would resolve: the config-wide default plus one
3466
- * per roster soul, normalized the way a dispatch normalizes it. A soul that
3467
- * pins its own provider resolves to different credentials entirely, so any
3468
- * check that looks only at the default misses exactly the agent that is
3469
- * misconfigured.
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 servedRuntimes = async (env, configPath) => {
3472
- const { applySoulPins } = await import('@stratusagent/gateway');
3473
- const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
3474
- const context = {
3475
- ...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
3476
- configPresent: location !== undefined,
3477
- };
3478
- const passes = [{ selection: {}, env }];
3479
- for (const entry of await loadRosterSouls(env, () => { })) {
3480
- const normalized = applySoulPins(entry.soul, {}, env, context);
3481
- passes.push({ selection: normalized.selection, env: normalized.env });
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
- const resolved = [];
3484
- for (const pass of passes) {
3485
- // A runtime that cannot resolve is the gateway's to report, per
3486
- // dispatch and with far better context than a startup pass has.
3487
- const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
3488
- if (runtime) {
3489
- resolved.push({ runtime, env: pass.env });
3490
- }
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
- return resolved;
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
- // The name stays theirs, but the id — the soul filename and the
3633
- // memory key — must be unique: the suggestion pool is small, so a
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
- const config = { ...globalConfig, provider: globalConfig.provider ?? soulProvider, soul: soulPath };
3676
- await mkdir(path.dirname(globalConfigPath(env)), { recursive: true });
3677
- 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);
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
- export const runDashboard = async (command, streams, env = {}) => {
3727
- const handle = await startDashboardServer({
3728
- host: command.host,
3729
- ...(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}` },
3730
3652
  });
3731
- writeLine(streams.stdout, `${DASHBOARD_TITLE} ready at ${handle.url}`);
3732
- 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.');
3733
3766
  if (command.openBrowser) {
3734
3767
  try {
3735
- await (env.openExternal ?? openExternalUrl)(handle.url);
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
- await handle.close();
3745
- return 0;
3786
+ ownDaemon.abort();
3746
3787
  }
3747
- await once(process, 'SIGINT');
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
- appToken: tokens.appToken,
3830
- botToken: tokens.botToken,
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
- // Headless is the only honest mode here until a turn can park and ask
3880
- // through a channel: there is no terminal behind a service manager, so
3881
- // `safe` runs and anything riskier is refused rather than waved through.
3882
- // Every refusal is logged — an unattended denial that appears nowhere
3883
- // reads like an agent that decided not to bother.
3884
- const approvals = createPermissionPolicy({
3885
- mode: 'headless',
3886
- onDecision: (decision) => {
3887
- if (decision.allowed) {
3888
- return;
3889
- }
3890
- warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
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') {