@stratusagent/cli 0.9.2 → 0.11.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
@@ -8,18 +8,19 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { spawn } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
12
13
  import os from 'node:os';
13
14
  import path from 'node:path';
14
15
  import { createInterface } from 'node:readline';
15
- import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, } from '@stratusagent/core';
16
+ import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, originOf, } from '@stratusagent/core';
16
17
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
17
18
  import { loadPlugins } from '@stratusagent/plugins';
18
- import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
19
+ import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, describeOriginScope, } from '@stratusagent/permissions';
19
20
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
20
21
  import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
21
- import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
22
- 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, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
22
+ import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, isLoadableSkillId, isValidAgentId, parseSoul, } from '@stratusagent/agents';
23
+ import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileCredentialResolver, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadNamedCredentials, discoverSkillsInDirectory, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, saveNamedCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
23
24
  import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
24
25
  export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
25
26
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -123,7 +124,16 @@ const defaultPackageInstaller = async (packages) => {
123
124
  });
124
125
  });
125
126
  };
126
- export const CLI_VERSION = '0.9.2';
127
+ /**
128
+ * What a credential name may be: the two conventions in use, and nothing
129
+ * that would be awkward in a soul's `credentials:` list — `search.apiKey`
130
+ * and environment-style `SLACK_TOKEN`. Leading letter required, which also
131
+ * happens to exclude `__proto__`; the store does not *rely* on that (it
132
+ * keys prototype-free maps), because a credentials file can be written by
133
+ * something other than this command.
134
+ */
135
+ export const CREDENTIAL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
136
+ export const CLI_VERSION = '0.11.0';
127
137
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
128
138
  /**
129
139
  * The range every package declares in its `engines` field. Two floors, not
@@ -162,6 +172,70 @@ export const unsupportedNodeMessage = (version) => {
162
172
  + "The gateway's session store uses node:sqlite, unflagged in 22.13.0 and, on the 23.x line, not until 23.4.0.\n"
163
173
  + 'Upgrade with `brew install node` on macOS, or your package manager or nvm on Linux.';
164
174
  };
175
+ /**
176
+ * The warning listeners to install in place of Node's default one, so the
177
+ * `node:sqlite` ExperimentalWarning stops being the first thing the CLI
178
+ * prints.
179
+ *
180
+ * The session store imports `node:sqlite`, which is experimental on every
181
+ * Node release this supports — that is exactly why
182
+ * {@link SUPPORTED_NODE_RANGE} is written the way it is — so the warning
183
+ * announces a dependency the project chose deliberately and a user can do
184
+ * nothing about. It landed above `stratusd ready` on every `stratus serve`,
185
+ * and on `stratus schedules`.
186
+ *
187
+ * Filtered rather than silenced wholesale (`--no-warnings` would be the
188
+ * blunt version): every other warning still reaches stderr through the
189
+ * listeners passed in, because a deprecation in our own dependencies is
190
+ * ours to act on.
191
+ */
192
+ export const withoutSqliteExperimentalWarning = (listeners) => (warning) => {
193
+ if (warning.name === 'ExperimentalWarning' && /\bSQLite\b/i.test(warning.message)) {
194
+ return;
195
+ }
196
+ for (const listener of listeners) {
197
+ listener(warning);
198
+ }
199
+ };
200
+ /**
201
+ * The name Node gives its own `warning` printer. Matched rather than assumed
202
+ * so that a Node which renames it simply stops being filtered — the warning
203
+ * comes back, which is noise, where guessing wrong would rewrite somebody
204
+ * else's listener.
205
+ */
206
+ const NODE_DEFAULT_WARNING_LISTENER = 'onWarning';
207
+ /**
208
+ * Swap Node's default warning printer for the filtered one above.
209
+ *
210
+ * Node installs its printer as an ordinary `warning` listener, so adding one
211
+ * alongside it would print twice rather than filter; the default has to come
212
+ * off and be handed to the replacement.
213
+ *
214
+ * **Only ever Node's own printer.** Wrapping a listener somebody else
215
+ * registered changes it in two ways that are not ours to change: `listeners()`
216
+ * hands back the *unwrapped* function for a `once` listener, so re-registering
217
+ * it makes it permanent — it then runs for every later warning instead of one
218
+ * — and a caller that keeps a reference can no longer `process.off('warning',
219
+ * theirs)`, because what is registered is this wrapper. So when anything other
220
+ * than the default is present — a `--require` preload, an embedding host — this
221
+ * does nothing at all and the warning stays. A host that took an interest in
222
+ * warnings owns them.
223
+ *
224
+ * Called once from the binary, before anything imports the session store.
225
+ */
226
+ export const filterSqliteExperimentalWarning = (process_ = process) => {
227
+ // rawListeners, not listeners: the wrapper is the registration, and only it
228
+ // carries the once-ness. It matters even here, where the guard below means
229
+ // the sole listener is Node's own — the next reader should not have to know
230
+ // that to see why this is safe.
231
+ const existing = process_.rawListeners('warning');
232
+ const [only] = existing;
233
+ if (existing.length !== 1 || only?.name !== NODE_DEFAULT_WARNING_LISTENER) {
234
+ return;
235
+ }
236
+ process_.removeAllListeners('warning');
237
+ process_.on('warning', withoutSqliteExperimentalWarning([only]));
238
+ };
165
239
  const HELP_TEXT = `Stratus Agent CLI
166
240
 
167
241
  Usage:
@@ -179,9 +253,14 @@ Usage:
179
253
  stratus agents --gateway http://127.0.0.1:4123
180
254
  stratus skill add stratuslabs/skill-code-review
181
255
  stratus skill add ./my-skills --skill code-review --agent ava
256
+ stratus skill validate ./my-skill
182
257
  stratus skills
258
+ stratus skill reload
259
+ stratus restart
183
260
  stratus schedules
184
261
  stratus schedules cancel <id>
262
+ printf %s "$BRAVE_KEY" | stratus credential set search.apiKey
263
+ stratus credentials
185
264
  stratus doctor
186
265
  stratus update
187
266
  stratus update --check
@@ -205,7 +284,9 @@ Commands:
205
284
  run Execute one local Stratus Agent session
206
285
  serve Run stratusd, the always-on gateway: durable sessions, the
207
286
  whole roster live at once (each agent on its own provider),
208
- delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
287
+ delegation, and a watchdog — one per home (it refuses to
288
+ start over a daemon already serving ~/.stratus), and
289
+ Ctrl+C / SIGTERM drains cleanly
209
290
  (--idle-timeout <seconds>, --approvals <headless|remote>,
210
291
  --no-events, --no-log-file, --config <path>); everything it
211
292
  says is also written to ~/.stratus/logs, which
@@ -227,9 +308,39 @@ Commands:
227
308
  (skills.sh-style repos). Installed is not enabled: a soul
228
309
  opts in via skills:, or pass --agent <id> to enable now.
229
310
  --skill <id> picks from a multi-skill repo (repeatable),
230
- --force replaces an already-installed id
311
+ --force replaces an already-installed id. A skill that
312
+ does not conform to the Agent Skills spec is refused,
313
+ naming what is wrong; one that installs with caveats
314
+ (fields another host owns, a bundled scripts/) says so.
315
+ A running daemon reloads its skills afterwards, no
316
+ restart (--no-reload skips that)
317
+ skill validate Check a skill directory, a directory of skills, or an
318
+ installed skill id against the Agent Skills spec — the
319
+ same check "skill add" runs, so what validates installs.
320
+ Exit 1 if anything would be refused
231
321
  skills List installed skills and which agents enable each
232
322
  (also: stratus skill list)
323
+ skill reload Ask the running daemon to re-read ~/.stratus/skills — for a
324
+ skill edited or removed by hand. A skill that will not
325
+ load refuses the whole reload and the previous set keeps
326
+ serving; nothing becomes reachable to an agent whose soul
327
+ does not list it
328
+ restart Ask the running daemon for an announced restart — what a
329
+ plugin change needs: it refuses new turns, lets in-flight
330
+ ones finish for up to --drain-timeout <seconds> (default
331
+ 30), then comes back with sessions, schedules, and
332
+ channels intact, under the service manager or not
333
+ credential set Store a named credential an agent can resolve — a search
334
+ backend asks for search.apiKey. The value is read from
335
+ stdin, never from a flag, so it stays out of your shell
336
+ history: printf %s "$KEY" | stratus credential set
337
+ search.apiKey. --agent <id> stores one agent's own key,
338
+ which outranks the shared entry for that agent. Storing
339
+ grants nothing: the soul still needs credentials: [name]
340
+ credentials List stored credential names and which agents have their
341
+ own — names only, never values (also: credential list)
342
+ credential remove
343
+ Forget one (--agent <id> for that agent's own entry)
233
344
  schedules List every schedule the fleet has set — cadence, prompt,
234
345
  pre-authorized destination, next firing — straight from the
235
346
  daemon's database (--format json). "stratus schedules
@@ -281,9 +392,16 @@ Options:
281
392
  --port dashboard: port for a daemon it starts (default: 4123)
282
393
  --host dashboard: host for a daemon it starts (default: 127.0.0.1)
283
394
  --no-open Do not open the browser automatically
284
- --gateway agents: read the roster from a running daemon's control API
395
+ --gateway agents / skill reload / restart: a running daemon's control
396
+ API (skill reload and restart default to the daemon
397
+ ~/.stratus/gateway.json names)
285
398
  --token Bearer token for --gateway (default: ~/.stratus/gateway-token)
399
+ --no-reload skill add: install without reloading a running daemon
400
+ --reason restart: why, for the daemon's log
401
+ --drain-timeout restart: seconds the daemon lets in-flight turns finish
402
+ before aborting them (default: 30)
286
403
  --no-api serve: do not serve the control API
404
+ --api serve: serve it even where the config says api.enabled: false
287
405
  --api-host serve: control API interface (default: 127.0.0.1)
288
406
  --api-port serve: control API port (default: 4123, 0 for any free port)
289
407
  --help, -h Show this help message
@@ -337,6 +455,23 @@ const readPromptFromStdin = async (stdin) => {
337
455
  }
338
456
  return data.trim();
339
457
  };
458
+ /**
459
+ * Stdin exactly as it arrived, for a value where whitespace may be part of
460
+ * it.
461
+ *
462
+ * `readPromptFromStdin` trims, which is right for a prompt and wrong for a
463
+ * secret: a key whose real value has a leading space would be stored as a
464
+ * *different* key, reported as stored, and then fail authentication with
465
+ * nothing to look at — this command never prints a value back.
466
+ */
467
+ const readSecretFromStdin = async (stdin) => {
468
+ stdin.setEncoding('utf8');
469
+ let data = '';
470
+ for await (const chunk of stdin) {
471
+ data += chunk;
472
+ }
473
+ return data;
474
+ };
340
475
  const readOptionValue = (tokens, index, flag) => {
341
476
  const value = tokens[index + 1];
342
477
  if (!value) {
@@ -433,6 +568,10 @@ export const parseCommand = (argv, env = {}) => {
433
568
  parsed.api = false;
434
569
  continue;
435
570
  }
571
+ if (token === '--api') {
572
+ parsed.api = true;
573
+ continue;
574
+ }
436
575
  if (token === '--api-port') {
437
576
  const port = Number(readOptionValue(rest, index, '--api-port'));
438
577
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
@@ -738,6 +877,63 @@ export const parseCommand = (argv, env = {}) => {
738
877
  ...(sessionId ? { sessionId } : {}),
739
878
  };
740
879
  }
880
+ if (command === 'credentials' || command === 'credential') {
881
+ const [subcommand, ...credentialRest] = command === 'credentials' ? ['list', ...rest] : rest;
882
+ if (subcommand === undefined || subcommand === '--help' || subcommand === '-h') {
883
+ return { command: 'help' };
884
+ }
885
+ if (subcommand !== 'set' && subcommand !== 'list' && subcommand !== 'remove') {
886
+ throw new Error(`No credential subcommand named ${JSON.stringify(subcommand)}. It is set, list, or remove.`);
887
+ }
888
+ let name;
889
+ let agentId;
890
+ for (let index = 0; index < credentialRest.length; index += 1) {
891
+ const token = credentialRest[index];
892
+ if (!token) {
893
+ continue;
894
+ }
895
+ if (token === '--help' || token === '-h') {
896
+ return { command: 'help' };
897
+ }
898
+ if (token === '--agent') {
899
+ agentId = readOptionValue(credentialRest, index, '--agent');
900
+ index += 1;
901
+ continue;
902
+ }
903
+ if (token.startsWith('--')) {
904
+ throw new Error(`Unknown option: ${token}`);
905
+ }
906
+ if (name !== undefined) {
907
+ throw new Error(`credential ${subcommand} takes one credential name; got both ${JSON.stringify(name)} and ${JSON.stringify(token)}.`);
908
+ }
909
+ name = token;
910
+ }
911
+ if (subcommand !== 'list' && name === undefined) {
912
+ throw new Error(`credential ${subcommand} needs a credential name — for a search backend that is search.apiKey.`);
913
+ }
914
+ // A name keys a map that a soul's `credentials:` list also names, so the
915
+ // two spellings have to be able to meet. The store itself is
916
+ // prototype-safe whatever lands in it; this rule is what stops a typo —
917
+ // or a name no soul could ever write — from being stored and then
918
+ // reported missing forever.
919
+ if (name !== undefined && !CREDENTIAL_NAME_PATTERN.test(name)) {
920
+ throw new Error(`${JSON.stringify(name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
921
+ + 'starting with a letter — search.apiKey, or an environment-style SLACK_TOKEN.');
922
+ }
923
+ // The agents package's own rule, not a second one: it is already the
924
+ // answer to "what may key a plain object", and it rejects the names —
925
+ // `__proto__`, `toString` — that would store a credential nothing could
926
+ // ever resolve.
927
+ if (agentId !== undefined && !isValidAgentId(agentId)) {
928
+ throw new Error(`${JSON.stringify(agentId)} cannot be an agent id, so a credential stored under it could never be resolved.`);
929
+ }
930
+ return {
931
+ command: 'credential',
932
+ action: subcommand,
933
+ ...(name !== undefined ? { name } : {}),
934
+ ...(agentId !== undefined ? { agentId } : {}),
935
+ };
936
+ }
741
937
  if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
742
938
  const skillsRest = command === 'skills' ? rest : rest.slice(1);
743
939
  for (const token of skillsRest) {
@@ -753,13 +949,57 @@ export const parseCommand = (argv, env = {}) => {
753
949
  if (subcommand === '--help' || subcommand === '-h') {
754
950
  return { command: 'help' };
755
951
  }
952
+ if (subcommand === 'validate') {
953
+ let target;
954
+ for (const token of skillRest) {
955
+ if (token === '--help' || token === '-h') {
956
+ return { command: 'help' };
957
+ }
958
+ if (token.startsWith('--')) {
959
+ throw new Error(`Unknown option: ${token}`);
960
+ }
961
+ if (target !== undefined) {
962
+ throw new Error(`skill validate takes one target; got both ${JSON.stringify(target)} and ${JSON.stringify(token)}.`);
963
+ }
964
+ target = token;
965
+ }
966
+ if (target === undefined) {
967
+ throw new Error('skill validate needs a target: a skill directory, a directory of skills, or an installed skill id.');
968
+ }
969
+ return { command: 'skill-validate', target };
970
+ }
971
+ if (subcommand === 'reload') {
972
+ const parsed = { command: 'skill-reload' };
973
+ for (let index = 0; index < skillRest.length; index += 1) {
974
+ const token = skillRest[index];
975
+ if (!token) {
976
+ continue;
977
+ }
978
+ if (token === '--help' || token === '-h') {
979
+ return { command: 'help' };
980
+ }
981
+ if (token === '--gateway') {
982
+ parsed.gateway = readOptionValue(skillRest, index, '--gateway');
983
+ index += 1;
984
+ continue;
985
+ }
986
+ if (token === '--token') {
987
+ parsed.token = readOptionValue(skillRest, index, '--token');
988
+ index += 1;
989
+ continue;
990
+ }
991
+ throw new Error(`Unknown option: ${token}`);
992
+ }
993
+ return parsed;
994
+ }
756
995
  if (subcommand !== 'add') {
757
- throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skills`);
996
+ throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skill validate <path>, stratus skill reload, stratus skills`);
758
997
  }
759
998
  let source;
760
999
  const skillIds = [];
761
1000
  let force = false;
762
1001
  let agentId;
1002
+ let reload = true;
763
1003
  for (let index = 0; index < skillRest.length; index += 1) {
764
1004
  const token = skillRest[index];
765
1005
  if (!token) {
@@ -782,6 +1022,10 @@ export const parseCommand = (argv, env = {}) => {
782
1022
  force = true;
783
1023
  continue;
784
1024
  }
1025
+ if (token === '--no-reload') {
1026
+ reload = false;
1027
+ continue;
1028
+ }
785
1029
  if (token.startsWith('--')) {
786
1030
  throw new Error(`Unknown option: ${token}`);
787
1031
  }
@@ -799,8 +1043,47 @@ export const parseCommand = (argv, env = {}) => {
799
1043
  ...(skillIds.length > 0 ? { skillIds } : {}),
800
1044
  ...(force ? { force } : {}),
801
1045
  ...(agentId !== undefined ? { agentId } : {}),
1046
+ ...(reload ? {} : { reload }),
802
1047
  };
803
1048
  }
1049
+ if (command === 'restart') {
1050
+ const parsed = { command: 'restart' };
1051
+ for (let index = 0; index < rest.length; index += 1) {
1052
+ const token = rest[index];
1053
+ if (!token) {
1054
+ continue;
1055
+ }
1056
+ if (token === '--help' || token === '-h') {
1057
+ return { command: 'help' };
1058
+ }
1059
+ if (token === '--reason') {
1060
+ parsed.reason = readOptionValue(rest, index, '--reason');
1061
+ index += 1;
1062
+ continue;
1063
+ }
1064
+ if (token === '--drain-timeout') {
1065
+ const seconds = Number(readOptionValue(rest, index, '--drain-timeout'));
1066
+ if (!Number.isFinite(seconds) || seconds < 0) {
1067
+ throw new Error(`Invalid value for --drain-timeout: ${rest[index + 1] ?? '(missing)'}`);
1068
+ }
1069
+ parsed.drainTimeoutMs = Math.round(seconds * 1000);
1070
+ index += 1;
1071
+ continue;
1072
+ }
1073
+ if (token === '--gateway') {
1074
+ parsed.gateway = readOptionValue(rest, index, '--gateway');
1075
+ index += 1;
1076
+ continue;
1077
+ }
1078
+ if (token === '--token') {
1079
+ parsed.token = readOptionValue(rest, index, '--token');
1080
+ index += 1;
1081
+ continue;
1082
+ }
1083
+ throw new Error(`Unknown option: ${token}`);
1084
+ }
1085
+ return parsed;
1086
+ }
804
1087
  if (command === 'agent') {
805
1088
  const [subcommand, ...agentRest] = rest;
806
1089
  if (subcommand === '--help' || subcommand === '-h') {
@@ -1120,7 +1403,56 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
1120
1403
  + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
1121
1404
  }
1122
1405
  };
1123
- const createApprovalPolicy = (mode, streams, env, ask) => {
1406
+ /**
1407
+ * Where a call would act, for a tool that answers that — read through
1408
+ * `originOf` so this and the daemon's engine compare the same thing.
1409
+ */
1410
+ const approvalOrigin = (context) => {
1411
+ const reported = context.tool.originFor?.(context.session);
1412
+ return reported === undefined ? undefined : originOf(reported);
1413
+ };
1414
+ /**
1415
+ * What a local prompt asks about: the tool, where it would act when that is
1416
+ * a question at all, and the arguments.
1417
+ *
1418
+ * The origin is not in the arguments and cannot be — a `browser.act` call
1419
+ * carries a CSS selector, and `#submit` is equally "load more results" and
1420
+ * "confirm purchase". A prompt that showed only the selector would be
1421
+ * asking someone to authorize an effect whose location it withheld, which
1422
+ * is the same reason the daemon's prompt and the Slack request name it.
1423
+ */
1424
+ export const describeApprovalCall = (context) => {
1425
+ const origin = approvalOrigin(context);
1426
+ return `${context.call.toolName}${origin ? ` on ${origin}` : ''}`
1427
+ + ` with input ${JSON.stringify(context.call.input)}`;
1428
+ };
1429
+ /**
1430
+ * Whether the conversation is still on the page the prompt named.
1431
+ *
1432
+ * A y/N at a terminal takes as long as it takes, and the page is live for
1433
+ * all of it: a redirect between the question and the answer would have a
1434
+ * yes given for one site click on another, with the prompt still showing
1435
+ * the first. So the origin is read again and a page that moved refuses —
1436
+ * the same check the daemon's engine makes after its own approval wait.
1437
+ * This local policy needs its own because it is a separate policy sharing
1438
+ * none of that code: `--approvals ask` judges the call, not a scope.
1439
+ *
1440
+ * Said out loud rather than failing quietly: somebody typed `y` and is owed
1441
+ * an explanation for why nothing happened.
1442
+ */
1443
+ const stillOnApprovedPage = (context, approved, streams) => {
1444
+ if (context.tool.originFor === undefined) {
1445
+ return true;
1446
+ }
1447
+ const now = approvalOrigin(context);
1448
+ if (now === approved) {
1449
+ return true;
1450
+ }
1451
+ writeLine(streams.stderr, `Not running ${context.call.toolName}: it was approved on ${approved ?? 'a page with no origin'}, `
1452
+ + `and the conversation ${now === undefined ? 'no longer has that page open' : `is on ${now} now`}.`);
1453
+ return false;
1454
+ };
1455
+ export const createApprovalPolicy = (mode, streams, env, ask) => {
1124
1456
  if (mode === 'always') {
1125
1457
  return new AllowAllApprovalPolicy();
1126
1458
  }
@@ -1135,25 +1467,27 @@ const createApprovalPolicy = (mode, streams, env, ask) => {
1135
1467
  // asker — two readers on one stream would race for the same bytes.
1136
1468
  if (ask) {
1137
1469
  return {
1138
- async approve({ call }) {
1139
- const answer = await ask(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
1140
- return /^y(es)?$/i.test(answer.trim());
1470
+ async approve(context) {
1471
+ const approved = approvalOrigin(context);
1472
+ const answer = await ask(`Approve tool call ${describeApprovalCall(context)}? [y/N] `);
1473
+ return /^y(es)?$/i.test(answer.trim()) && stillOnApprovedPage(context, approved, streams);
1141
1474
  },
1142
1475
  };
1143
1476
  }
1144
1477
  return {
1145
- async approve({ call }) {
1478
+ async approve(context) {
1146
1479
  const input = env.approvalInput ?? process.stdin;
1147
1480
  const readline = createInterface({ input, terminal: false });
1481
+ const approved = approvalOrigin(context);
1148
1482
  // Prompt on stderr so stdout stays parseable (e.g. --format json).
1149
- streams.stderr.write(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
1483
+ streams.stderr.write(`Approve tool call ${describeApprovalCall(context)}? [y/N] `);
1150
1484
  try {
1151
1485
  const answer = await new Promise((resolve) => {
1152
1486
  readline.once('line', resolve);
1153
1487
  readline.once('close', () => resolve(''));
1154
1488
  });
1155
1489
  writeLine(streams.stderr);
1156
- return /^y(es)?$/i.test(answer.trim());
1490
+ return /^y(es)?$/i.test(answer.trim()) && stillOnApprovedPage(context, approved, streams);
1157
1491
  }
1158
1492
  finally {
1159
1493
  readline.close();
@@ -1220,6 +1554,7 @@ const createAgentRuntime = async (streams, options) => {
1220
1554
  tools,
1221
1555
  skills,
1222
1556
  bus,
1557
+ credentials: createFileCredentialResolver(runEnv),
1223
1558
  workspaceRoot: workspacesDirPath(runEnv),
1224
1559
  });
1225
1560
  loadedPlugins.push(...result.loaded);
@@ -3086,9 +3421,10 @@ export const runSetup = async (command, streams, env = {}) => {
3086
3421
  if (chosen.length > 0) {
3087
3422
  const packages = chosen.flatMap((group) => group.packages);
3088
3423
  writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
3089
- // Never fails setup, for the same reason the service install does not:
3090
- // the config and credentials are already written, and a package that
3091
- // did not install is a warning at the next start, not a broken machine.
3424
+ // Never fails setup — not even its exit code, unlike the always-on
3425
+ // service step below: the config and credentials are already written,
3426
+ // and a package that did not install is a warning at the next start,
3427
+ // not a broken machine.
3092
3428
  const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
3093
3429
  .catch((error) => ({
3094
3430
  ok: false,
@@ -3115,7 +3451,26 @@ export const runSetup = async (command, streams, env = {}) => {
3115
3451
  : `Skipped. Install them yourself with: npm install -g ${rest}`);
3116
3452
  }
3117
3453
  };
3454
+ /**
3455
+ * Write everything the menu decided, then report whether the always-on
3456
+ * service step it performed — installing the service, or removing one the
3457
+ * user chose not to run — succeeded.
3458
+ *
3459
+ * A boolean rather than a throw: the service is the one optional part of
3460
+ * setup and everything else is already on disk by the time it runs, so
3461
+ * failing outright would lose the saved config to a service that can be
3462
+ * installed (or removed) later. The exit code carries it instead — the
3463
+ * same answer `stratus service install` and `stratus service uninstall`
3464
+ * give for the identical failure, which is what a script driving setup
3465
+ * has to be able to see.
3466
+ */
3118
3467
  const save = async () => {
3468
+ // The always-on service step is the only one here that can fail without
3469
+ // taking the rest of setup with it, so it is the only one the exit code
3470
+ // has to carry. Both signs count: a unit left in place after "do not run
3471
+ // it for me" starts a daemon at login the user asked not to have, which
3472
+ // is no more a successful setup than one that will not come up at all.
3473
+ let serviceStepFailed = false;
3119
3474
  const config = { provider: state.provider };
3120
3475
  if (state.provider !== 'demo') {
3121
3476
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -3208,6 +3563,9 @@ export const runSetup = async (command, streams, env = {}) => {
3208
3563
  for (const message of removed.messages) {
3209
3564
  writeLine(removed.ok ? streams.stdout : streams.stderr, message);
3210
3565
  }
3566
+ if (!removed.ok) {
3567
+ serviceStepFailed = true;
3568
+ }
3211
3569
  }
3212
3570
  }
3213
3571
  else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
@@ -3233,6 +3591,7 @@ export const runSetup = async (command, streams, env = {}) => {
3233
3591
  }
3234
3592
  if (!result.ok) {
3235
3593
  writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
3594
+ serviceStepFailed = true;
3236
3595
  }
3237
3596
  }
3238
3597
  writeLine(streams.stdout);
@@ -3283,6 +3642,7 @@ export const runSetup = async (command, streams, env = {}) => {
3283
3642
  if (dashboardReady) {
3284
3643
  writeLine(streams.stdout, ' stratus dashboard');
3285
3644
  }
3645
+ return !serviceStepFailed;
3286
3646
  };
3287
3647
  try {
3288
3648
  if (!interactive) {
@@ -3326,8 +3686,13 @@ export const runSetup = async (command, streams, env = {}) => {
3326
3686
  await testRun();
3327
3687
  }
3328
3688
  }
3329
- await save();
3330
- return 0;
3689
+ // Non-zero when the always-on service step failed, matching `stratus
3690
+ // service install` and `stratus service uninstall`. Everything setup
3691
+ // saved is still saved, and the output above says so — but a daemon that
3692
+ // will not come up at login, or one that will after the user said not to
3693
+ // run it, is not a successful setup, and a script that only reads the
3694
+ // exit code had no way to tell.
3695
+ return (await save()) ? 0 : 1;
3331
3696
  }
3332
3697
  finally {
3333
3698
  prompter.close();
@@ -4376,6 +4741,18 @@ export const runSkillAdd = async (command, streams, env = {}) => {
4376
4741
  });
4377
4742
  for (const skill of result.installed) {
4378
4743
  writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
4744
+ // The spec's compatibility field is written for exactly this reader:
4745
+ // the person deciding whether this environment can carry the skill.
4746
+ if (skill.compatibility !== undefined) {
4747
+ writeLine(streams.stdout, ` compatibility: ${skill.compatibility}`);
4748
+ }
4749
+ }
4750
+ // What installed with a caveat — a field another host owns, the
4751
+ // legacy key form, a bundled scripts/ — is said next to the install,
4752
+ // per skill: the operator deciding to enable it is the one who needs
4753
+ // to hear it, and this is the moment they are looking.
4754
+ for (const warning of result.warnings) {
4755
+ writeLine(streams.stderr, `Warning: ${warning.id}: ${warning.message}`);
4379
4756
  }
4380
4757
  // Present already is a no-op, not a failure — and exactly what the
4381
4758
  // "rerun with --agent" hint below produces, so these stay eligible
@@ -4392,6 +4769,13 @@ export const runSkillAdd = async (command, streams, env = {}) => {
4392
4769
  : `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
4393
4770
  return 1;
4394
4771
  }
4772
+ // The ordinary path needs no second step: a daemon that is running
4773
+ // serves what was just installed from its next turn. Only when
4774
+ // something changed on disk — a re-run that installed nothing has
4775
+ // nothing to reload — and only where a daemon has said it is running.
4776
+ if (result.installed.length > 0 && command.reload !== false) {
4777
+ await reloadRunningDaemonSkills(streams, env);
4778
+ }
4395
4779
  // Installed is not enabled: a soul opts in through its skills:
4396
4780
  // allowlist, and that stays true when the install came through a
4397
4781
  // command. --agent is the explicit way to say both at once.
@@ -4479,6 +4863,279 @@ export const runSchedules = async (command, streams, env = {}) => {
4479
4863
  store.close();
4480
4864
  }
4481
4865
  };
4866
+ /**
4867
+ * `stratus credential set|list|remove`: the place a named credential goes.
4868
+ *
4869
+ * A credential nobody can add is a credential nobody has, which is why this
4870
+ * exists at all — a search backend asks for `search.apiKey` and until now
4871
+ * there was nowhere to put one. Three rules it keeps, all of them
4872
+ * deliberate:
4873
+ *
4874
+ * The value is read from **stdin, never from a flag**. A secret in argv is
4875
+ * a secret in shell history and in every `ps` on the machine.
4876
+ *
4877
+ * Nothing here ever prints a value back. `list` reports names and which
4878
+ * agents have their own, the same posture the control API's credential read
4879
+ * already keeps.
4880
+ *
4881
+ * And storing one grants no agent anything: the agent's soul still has to
4882
+ * list the name under `credentials:`, which is the per-identity gate.
4883
+ */
4884
+ export const runCredential = async (command, streams, env = {}) => {
4885
+ const named = await loadNamedCredentials(env);
4886
+ if (command.action === 'list') {
4887
+ const shared = Object.keys(named.shared).sort();
4888
+ const agents = Object.entries(named.agents).sort(([a], [b]) => a.localeCompare(b));
4889
+ if (shared.length === 0 && agents.length === 0) {
4890
+ writeLine(streams.stdout, `No named credentials stored in ${credentialsPath(env)}.`);
4891
+ writeLine(streams.stdout, 'A search backend wants `stratus credential set search.apiKey`.');
4892
+ return 0;
4893
+ }
4894
+ if (shared.length > 0) {
4895
+ writeLine(streams.stdout, 'Shared with the whole fleet:');
4896
+ for (const name of shared) {
4897
+ writeLine(streams.stdout, ` ${name}`);
4898
+ }
4899
+ }
4900
+ for (const [agentId, entries] of agents) {
4901
+ writeLine(streams.stdout, `Only ${agentId}:`);
4902
+ for (const name of Object.keys(entries).sort()) {
4903
+ writeLine(streams.stdout, ` ${name}`);
4904
+ }
4905
+ }
4906
+ // Names, never values — and say so, so nobody goes looking for a flag
4907
+ // that prints one.
4908
+ writeLine(streams.stdout, '');
4909
+ writeLine(streams.stdout, `Values are never printed. They live in ${credentialsPath(env)}, readable only by you.`);
4910
+ return 0;
4911
+ }
4912
+ const name = command.name ?? '';
4913
+ if (command.action === 'remove') {
4914
+ const scope = command.agentId;
4915
+ const store = scope === undefined ? named.shared : named.agents[scope];
4916
+ if (!store || store[name] === undefined) {
4917
+ writeLine(streams.stderr, scope === undefined
4918
+ ? `No shared credential named ${name}. \`stratus credentials\` lists what is stored.`
4919
+ : `Agent ${scope} has no credential of its own named ${name}. \`stratus credentials\` lists what is stored.`);
4920
+ return 1;
4921
+ }
4922
+ delete store[name];
4923
+ if (scope !== undefined && Object.keys(store).length === 0) {
4924
+ delete named.agents[scope];
4925
+ }
4926
+ await saveNamedCredentials(env, named);
4927
+ writeLine(streams.stdout, scope === undefined ? `Removed ${name}.` : `Removed ${name} for ${scope}.`);
4928
+ return 0;
4929
+ }
4930
+ writeLine(streams.stderr, `Reading the value for ${name} from stdin — it is never taken from the command line, where it would land in your shell history. Ctrl-D when done.`);
4931
+ const arrived = env.stdin ?? await readSecretFromStdin(env.stdinStream ?? process.stdin);
4932
+ // One trailing line terminator and nothing else. `echo "$KEY" |` appends a
4933
+ // newline that is not part of the key and `printf %s` appends nothing, so
4934
+ // both spellings have to work — while a key whose own value ends in a
4935
+ // space must survive either of them, which a blanket trim would not allow.
4936
+ const value = arrived.replace(/\r?\n$/, '');
4937
+ if (value.trim().length === 0) {
4938
+ writeLine(streams.stderr, `Nothing arrived on stdin, so ${name} was not stored. Pipe the value in: \`printf %s "$KEY" | stratus credential set ${name}\`.`);
4939
+ return 1;
4940
+ }
4941
+ if (command.agentId === undefined) {
4942
+ named.shared[name] = value;
4943
+ }
4944
+ else {
4945
+ named.agents[command.agentId] = { ...(named.agents[command.agentId] ?? {}), [name]: value };
4946
+ }
4947
+ await saveNamedCredentials(env, named);
4948
+ writeLine(streams.stdout, command.agentId === undefined
4949
+ ? `Stored ${name} in ${credentialsPath(env)} (readable only by you).`
4950
+ : `Stored ${name} for ${command.agentId} in ${credentialsPath(env)} (readable only by you). It outranks the shared entry for that agent.`);
4951
+ // Storing grants nothing: the soul is the second gate, and an operator
4952
+ // who stops here gets "not allowed to access credential" on every call.
4953
+ writeLine(streams.stdout, `Each agent that may use it still needs \`credentials: [${name}]\` in its soul.`);
4954
+ return 0;
4955
+ };
4956
+ /**
4957
+ * `stratus skill validate <target>`: the install-time check, run without
4958
+ * installing — for an author about to publish a skill, or an operator
4959
+ * asking why one was refused. A local path is a skill directory or a
4960
+ * directory of skills, discovered exactly as `skill add` would; a bare id
4961
+ * names an installed skill under `~/.stratus/skills/`. Exit 1 when
4962
+ * anything would be refused, so a publish step can gate on it.
4963
+ */
4964
+ export const runSkillValidate = async (command, streams, env = {}) => {
4965
+ let directory;
4966
+ let installed = false;
4967
+ const localPath = path.resolve(readWorkingDirectory(env), command.target);
4968
+ try {
4969
+ if ((await stat(localPath)).isDirectory()) {
4970
+ directory = localPath;
4971
+ }
4972
+ }
4973
+ catch {
4974
+ // Not a local directory; try it as an installed id.
4975
+ }
4976
+ // The loader's rule for the lookup, so a directory that loads with a
4977
+ // pre-spec id can be validated — and told what to rename.
4978
+ if (directory === undefined && isLoadableSkillId(command.target)) {
4979
+ const installedPath = path.join(skillsDirPath(env), command.target);
4980
+ try {
4981
+ if ((await stat(installedPath)).isDirectory()) {
4982
+ directory = installedPath;
4983
+ installed = true;
4984
+ }
4985
+ }
4986
+ catch {
4987
+ // Not installed either.
4988
+ }
4989
+ }
4990
+ if (directory === undefined) {
4991
+ writeLine(streams.stderr, `Error: ${JSON.stringify(command.target)} is neither a directory nor an installed skill id (stratus skills lists those).`);
4992
+ return 1;
4993
+ }
4994
+ // An installed directory IS the layout the spec's directory rule is
4995
+ // about, so its name is checked; a path the author typed is a checkout
4996
+ // or a container, judged as `skill add` would judge it.
4997
+ const { candidates, skipped } = await discoverSkillsInDirectory(directory, installed ? { checkRootDirectoryName: true } : {});
4998
+ if (candidates.length === 0 && skipped.length === 0) {
4999
+ writeLine(streams.stderr, `Error: no skills found in ${directory}. A skill is a directory with a SKILL.md.`);
5000
+ return 1;
5001
+ }
5002
+ for (const candidate of candidates) {
5003
+ const caveat = candidate.warnings.length > 0
5004
+ ? ` — ${candidate.warnings.length} warning${candidate.warnings.length === 1 ? '' : 's'}`
5005
+ : '';
5006
+ writeLine(streams.stdout, `${candidate.id}: ok${caveat}`);
5007
+ for (const warning of candidate.warnings) {
5008
+ writeLine(streams.stdout, ` warning: ${warning}`);
5009
+ }
5010
+ }
5011
+ for (const skip of skipped) {
5012
+ writeLine(streams.stdout, `${skip.id}: refused`);
5013
+ writeLine(streams.stdout, ` error: ${skip.reason}`);
5014
+ }
5015
+ if (skipped.length > 0) {
5016
+ const noun = skipped.length === 1 ? 'skill' : 'skills';
5017
+ writeLine(streams.stderr, `Error: ${skipped.length} ${noun} would be refused at install.`);
5018
+ return 1;
5019
+ }
5020
+ return 0;
5021
+ };
5022
+ /**
5023
+ * The control API base a command should talk to: `--gateway` when given,
5024
+ * else whatever daemon `~/.stratus/gateway.json` says is serving, else
5025
+ * nothing — the file is written when the API binds and removed when it
5026
+ * stops, so its absence means no daemon has said where it is.
5027
+ */
5028
+ const runningGatewayBase = async (env, target) => {
5029
+ if (target.gateway) {
5030
+ return target.gateway.replace(/\/+$/, '');
5031
+ }
5032
+ const info = await readGatewayInfo(env);
5033
+ return info?.url.replace(/\/+$/, '');
5034
+ };
5035
+ /** One authenticated call to a daemon's control API, with the network and auth failures said plainly. */
5036
+ const callRunningGateway = async (env, target, base, pathname, body) => {
5037
+ const token = await gatewayToken(env, target.token);
5038
+ const fetchImpl = env.fetch ?? globalThis.fetch;
5039
+ if (typeof fetchImpl !== 'function') {
5040
+ throw new Error('fetch is unavailable, so this runtime cannot reach a daemon.');
5041
+ }
5042
+ let response;
5043
+ try {
5044
+ response = await fetchImpl(`${base}${pathname}`, {
5045
+ method: 'POST',
5046
+ headers: {
5047
+ authorization: `Bearer ${token}`,
5048
+ ...(body ? { 'content-type': 'application/json' } : {}),
5049
+ },
5050
+ ...(body ? { body: JSON.stringify(body) } : {}),
5051
+ });
5052
+ }
5053
+ catch (error) {
5054
+ throw new Error(`Could not reach the gateway at ${base} (${error instanceof Error ? error.message : String(error)}). `
5055
+ + 'Is stratusd running, and does it have @stratusagent/control-api installed?');
5056
+ }
5057
+ if (response.status === 401 || response.status === 403) {
5058
+ throw new Error(`The gateway at ${base} rejected this token. Check --token, STRATUS_GATEWAY_TOKEN, or ~/.stratus/gateway-token.`);
5059
+ }
5060
+ return response;
5061
+ };
5062
+ /** The API's own sentence for a failure, or the status when it sent none. */
5063
+ const gatewayErrorMessage = async (response) => {
5064
+ try {
5065
+ const payload = await response.json();
5066
+ if (payload.error?.message) {
5067
+ return payload.error.message;
5068
+ }
5069
+ }
5070
+ catch {
5071
+ // Not JSON; the status is all there is to say.
5072
+ }
5073
+ return `HTTP ${response.status}`;
5074
+ };
5075
+ const noRunningDaemonMessage = (env) => `no running daemon found — ${gatewayInfoPath(env)} does not exist, and no --gateway was given. `
5076
+ + 'Start one with `stratus serve` or `stratus service start`; a daemon loads ~/.stratus/skills at start.';
5077
+ /**
5078
+ * After `skill add`: tell the daemon, if one says it is running. Silent
5079
+ * when none does — a `stratus run` user with no daemon should not read a
5080
+ * note about one — and a warning, never a failure, when the file names a
5081
+ * daemon that did not answer: the install itself succeeded.
5082
+ */
5083
+ const reloadRunningDaemonSkills = async (streams, env) => {
5084
+ const info = await readGatewayInfo(env);
5085
+ if (!info) {
5086
+ return;
5087
+ }
5088
+ const base = info.url.replace(/\/+$/, '');
5089
+ try {
5090
+ const response = await callRunningGateway(env, {}, base, '/api/v1/skills/reload');
5091
+ if (!response.ok) {
5092
+ throw new Error(await gatewayErrorMessage(response));
5093
+ }
5094
+ writeLine(streams.stdout, `reloaded the running daemon's skills (${base}) — no restart needed`);
5095
+ }
5096
+ catch (error) {
5097
+ writeLine(streams.stderr, `Warning: ${gatewayInfoPath(env)} names a daemon at ${base}, but its skills were not reloaded: `
5098
+ + `${error instanceof Error ? error.message : String(error)} If it is running, reload it with: stratus skill reload`);
5099
+ }
5100
+ };
5101
+ export const runSkillReload = async (command, streams, env = {}) => {
5102
+ const base = await runningGatewayBase(env, command);
5103
+ if (!base) {
5104
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5105
+ return 1;
5106
+ }
5107
+ const response = await callRunningGateway(env, command, base, '/api/v1/skills/reload');
5108
+ if (!response.ok) {
5109
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5110
+ return 1;
5111
+ }
5112
+ const { skills } = await response.json();
5113
+ const ids = (skills ?? []).map((skill) => skill.id);
5114
+ writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
5115
+ return 0;
5116
+ };
5117
+ export const runRestart = async (command, streams, env = {}) => {
5118
+ const base = await runningGatewayBase(env, command);
5119
+ if (!base) {
5120
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5121
+ return 1;
5122
+ }
5123
+ const response = await callRunningGateway(env, command, base, '/api/v1/restart', {
5124
+ reason: command.reason ?? 'stratus restart',
5125
+ ...(command.drainTimeoutMs !== undefined ? { drainTimeoutMs: command.drainTimeoutMs } : {}),
5126
+ });
5127
+ if (!response.ok) {
5128
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5129
+ return 1;
5130
+ }
5131
+ const status = await response.json();
5132
+ const inflight = status.inflight ?? 0;
5133
+ const window = Math.round((status.drainTimeoutMs ?? 0) / 1000);
5134
+ writeLine(streams.stdout, `restart announced to the daemon at ${base} — new turns are refused; ${inflight} turn(s) in flight `
5135
+ + `get up to ${window}s to finish, then it comes back.`);
5136
+ writeLine(streams.stdout, 'Watch it come back: stratus logs -f');
5137
+ return 0;
5138
+ };
4482
5139
  export const runSkills = async (streams, env = {}) => {
4483
5140
  const registry = new SkillRegistry();
4484
5141
  const skills = await loadOperatorSkills(env, registry, (line) => {
@@ -4723,6 +5380,85 @@ const gatewayAnswering = async (env, base, fetchImpl) => {
4723
5380
  return false;
4724
5381
  }
4725
5382
  };
5383
+ /**
5384
+ * Whether a process with this pid exists. EPERM is an answer — the process
5385
+ * is there, it is just not ours to signal.
5386
+ */
5387
+ const processAlive = (pid) => {
5388
+ try {
5389
+ process.kill(pid, 0);
5390
+ return true;
5391
+ }
5392
+ catch (error) {
5393
+ return error.code === 'EPERM';
5394
+ }
5395
+ };
5396
+ /**
5397
+ * The error for a home another daemon holds (see `claimHome` in
5398
+ * @stratusagent/gateway — the claim is what decides; this only says who).
5399
+ *
5400
+ * The discovery file names the holder when it has one to name: a daemon
5401
+ * that has bound its API. Read for the message alone, and only trusted as
5402
+ * far as a live pid — a daemon still starting has not written it, a
5403
+ * daemon draining its last turn has already removed it, and a SIGKILLed
5404
+ * one leaves it behind for the next pid to inherit. Not probed over HTTP:
5405
+ * a `STRATUS_GATEWAY_TOKEN` exported for some remote gateway would answer
5406
+ * 401 for the local one, and the claim needs no second opinion.
5407
+ */
5408
+ /**
5409
+ * `stratus serve` refused because another daemon holds the home. Its own
5410
+ * type so `stratus dashboard` can tell "a daemon is there, wait for it to
5411
+ * publish" from "the daemon could not start".
5412
+ */
5413
+ class HomeHeldError extends Error {
5414
+ constructor(message) {
5415
+ super(message);
5416
+ this.name = 'HomeHeldError';
5417
+ }
5418
+ }
5419
+ /**
5420
+ * Whether a daemon from before the lock existed is serving this home.
5421
+ *
5422
+ * Such a daemon — alive across an upgrade that did not stop it — never
5423
+ * took the claim, so the claim alone cannot see it. The discovery file it
5424
+ * published is its only trace, held to the two proofs the lock made
5425
+ * unnecessary for everything since: the pid it names is alive, and its
5426
+ * URL answers `/health` with this home's own token file — never the
5427
+ * `STRATUS_GATEWAY_TOKEN` override, which names some other gateway and
5428
+ * would make a live daemon read as absent. A stale file fails either
5429
+ * proof and refuses nothing.
5430
+ */
5431
+ const legacyDaemonServing = async (env) => {
5432
+ const info = await readGatewayInfo(env);
5433
+ if (info?.pid === undefined || info.pid === process.pid || !processAlive(info.pid)) {
5434
+ return false;
5435
+ }
5436
+ const fetchImpl = env.fetch ?? globalThis.fetch;
5437
+ if (typeof fetchImpl !== 'function') {
5438
+ return false;
5439
+ }
5440
+ try {
5441
+ const token = (await readFile(gatewayTokenPath(env), 'utf8')).trim();
5442
+ const response = await fetchImpl(`${info.url}/api/v1/health`, {
5443
+ headers: { authorization: `Bearer ${token}` },
5444
+ signal: AbortSignal.timeout(2_000),
5445
+ });
5446
+ return response.ok;
5447
+ }
5448
+ catch {
5449
+ return false;
5450
+ }
5451
+ };
5452
+ const describeHeldHome = async (env) => {
5453
+ const info = await readGatewayInfo(env);
5454
+ const holder = info?.pid !== undefined && processAlive(info.pid)
5455
+ ? ` (pid ${info.pid}, ${info.url})`
5456
+ : ' — one that is still starting, or still draining its last turns';
5457
+ return `stratusd is already running for this home${holder}. Two daemons on one ~/.stratus `
5458
+ + 'would each fire the other\'s schedules and re-ask its approvals. Stop it first: `stratus service stop` '
5459
+ + 'if it is the installed service, otherwise Ctrl+C where it runs — and let it finish; it holds the home until '
5460
+ + 'its last turn is written.';
5461
+ };
4726
5462
  /**
4727
5463
  * `stratus dashboard` — open the web UI against a running daemon, starting
4728
5464
  * one in the foreground when there is none.
@@ -4745,56 +5481,87 @@ export const runDashboard = async (command, streams, env = {}) => {
4745
5481
  if (!base) {
4746
5482
  writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
4747
5483
  writeLine(streams.stdout, 'Run `stratus service install` to keep one running instead.');
4748
- serving = runServe({
4749
- command: 'serve',
4750
- events: false,
4751
- // Explicitly on. A trusted config may set `api.enabled: false` — a
4752
- // reasonable thing for a headless box — but this command exists to
4753
- // open the dashboard, and honouring it here would start a daemon
4754
- // with no API and then time out waiting for the one it promised.
4755
- api: true,
4756
- ...(command.port !== undefined ? { apiPort: command.port } : {}),
4757
- // Passed whenever it was asked for, including when it is the default.
4758
- // `--host 127.0.0.1` against a config saying `0.0.0.0` is an operator
4759
- // narrowing the bind, and dropping it because it matched the default
4760
- // did the reverse of what they typed.
4761
- ...(command.host !== undefined ? { apiHost: command.host } : {}),
4762
- },
4763
- // The daemon's own chatter belongs on stderr here: stdout is where this
4764
- // command says where to point a browser, and interleaving the two makes
4765
- // the one line that matters hard to find.
4766
- { stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
4767
- // Attached now, not at the end. `runServe` can reject before it publishes
4768
- // anything — an unreadable roster, a session store that will not open —
4769
- // and a rejected promise nobody is watching for fifteen seconds is an
4770
- // unhandled rejection, which terminates the process instead of reaching
4771
- // the message below. Captured rather than swallowed, so the reason the
4772
- // daemon gave is the reason this command reports.
5484
+ // One attempt at a daemon of our own. `runServe` can reject before it
5485
+ // publishes anything — an unreadable roster, a session store that will
5486
+ // not open — and a rejected promise nobody is watching for fifteen
5487
+ // seconds is an unhandled rejection, which terminates the process
5488
+ // instead of reaching the message below. Captured rather than
5489
+ // swallowed, so the reason the daemon gave is the reason this command
5490
+ // reports.
4773
5491
  let daemonFailure;
4774
- const daemonExit = serving.then(() => undefined, (error) => { daemonFailure = error; });
4775
- // Gated on the daemon actually answering, not on a delay: it publishes
5492
+ let daemonSettled = false;
5493
+ let daemonExit = Promise.resolve();
5494
+ let attemptedAt = 0;
5495
+ const attempt = () => {
5496
+ daemonFailure = undefined;
5497
+ daemonSettled = false;
5498
+ attemptedAt = Date.now();
5499
+ serving = runServe({
5500
+ command: 'serve',
5501
+ events: false,
5502
+ // Explicitly on. A trusted config may set `api.enabled: false` —
5503
+ // a reasonable thing for a headless box — but this command exists
5504
+ // to open the dashboard, and honouring it here would start a
5505
+ // daemon with no API and then time out waiting for the one it
5506
+ // promised.
5507
+ api: true,
5508
+ ...(command.port !== undefined ? { apiPort: command.port } : {}),
5509
+ // Passed whenever it was asked for, including when it is the
5510
+ // default. `--host 127.0.0.1` against a config saying `0.0.0.0`
5511
+ // is an operator narrowing the bind, and dropping it because it
5512
+ // matched the default did the reverse of what they typed.
5513
+ ...(command.host !== undefined ? { apiHost: command.host } : {}),
5514
+ },
5515
+ // The daemon's own chatter belongs on stderr here: stdout is where
5516
+ // this command says where to point a browser, and interleaving the
5517
+ // two makes the one line that matters hard to find.
5518
+ { stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
5519
+ daemonExit = serving.then(() => { daemonSettled = true; }, (error) => { daemonFailure = error; daemonSettled = true; });
5520
+ };
5521
+ attempt();
5522
+ // Gated on a daemon actually answering, not on a delay: one publishes
4776
5523
  // where it bound the moment it binds, and anything less would be a race
4777
- // dressed up as a timeout. It also stops the moment the daemon gives up,
4778
- // rather than waiting out a timeout for a process that is already gone.
5524
+ // dressed up as a timeout. It also stops the moment our daemon gives up
5525
+ // — except when it gave up because another one holds the home. An
5526
+ // installed service still starting has not published its address yet
5527
+ // and is about to: that is the daemon to wait for. And a daemon still
5528
+ // draining may simply exit, with nothing replacing it — so while the
5529
+ // home is held, our own attempt is repeated about once a second, and
5530
+ // takes the home the moment it is free.
4779
5531
  const startedBy = Date.now() + 15_000;
4780
- let daemonStopped = false;
4781
- void daemonExit.then(() => { daemonStopped = true; });
4782
- while (!base && !daemonStopped && Date.now() < startedBy) {
5532
+ while (!base && Date.now() < startedBy) {
4783
5533
  const info = await readGatewayInfo(env);
4784
5534
  if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
4785
5535
  base = info.url;
4786
5536
  break;
4787
5537
  }
5538
+ if (daemonSettled) {
5539
+ if (!(daemonFailure instanceof HomeHeldError)) {
5540
+ break;
5541
+ }
5542
+ if (Date.now() - attemptedAt >= 1_000) {
5543
+ attempt();
5544
+ }
5545
+ }
4788
5546
  await new Promise((resolve) => setTimeout(resolve, 150));
4789
5547
  }
4790
5548
  if (!base) {
4791
5549
  ownDaemon.abort();
4792
5550
  await daemonExit;
4793
- writeLine(streams.stderr, daemonFailure
4794
- ? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
4795
- : 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
5551
+ writeLine(streams.stderr, daemonFailure instanceof HomeHeldError
5552
+ ? 'Error: another daemon holds this home but never published its address. It may still be starting or '
5553
+ + 'draining — try again in a moment, or check `stratus service status`.'
5554
+ : daemonFailure
5555
+ ? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
5556
+ : 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
4796
5557
  return 1;
4797
5558
  }
5559
+ if (daemonFailure instanceof HomeHeldError) {
5560
+ // The daemon that answered is the one that held the home. Ours never
5561
+ // started, so there is nothing of ours to wait for or to stop: from
5562
+ // here this is the found-a-running-daemon case.
5563
+ serving = undefined;
5564
+ }
4798
5565
  }
4799
5566
  let url;
4800
5567
  try {
@@ -4830,7 +5597,15 @@ export const runDashboard = async (command, streams, env = {}) => {
4830
5597
  await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
4831
5598
  ownDaemon.abort();
4832
5599
  }
4833
- return serving;
5600
+ // An attempt still in flight when the daemon answered may yet be refused
5601
+ // — the holder published while ours was claiming. The dashboard found a
5602
+ // daemon; that refusal is not its failure.
5603
+ return serving.catch((error) => {
5604
+ if (error instanceof HomeHeldError) {
5605
+ return 0;
5606
+ }
5607
+ throw error;
5608
+ });
4834
5609
  };
4835
5610
  /**
4836
5611
  * Resolution and loading are separate questions, and only the first one
@@ -4933,6 +5708,214 @@ const loadServePlugins = async (env, configPath, warn) => {
4933
5708
  * roster, accept dispatches, print events, and drain cleanly on SIGTERM,
4934
5709
  * SIGINT, or the injected shutdown signal.
4935
5710
  */
5711
+ /**
5712
+ * What a daemon exits with to ask for a fresh process. Only ever seen by the
5713
+ * supervisor below, which is the only thing that starts a daemon with the
5714
+ * environment marker that makes it exit this way instead of supervising.
5715
+ */
5716
+ export const RESTART_EXIT_CODE = 75;
5717
+ /**
5718
+ * What a daemon exits with when a restart could not drain — a turn that
5719
+ * ignored its abort, a plugin or channel that did not let go. Distinct from
5720
+ * the restart status on purpose: the supervisor answers this one by
5721
+ * exiting with it too, so the process the service manager started ends
5722
+ * and the manager restarts the whole unit — under systemd, cleaning the
5723
+ * cgroup of whatever the plugin left running. A supervisor that started
5724
+ * another daemon instead would keep that cgroup, and its leaks, alive.
5725
+ */
5726
+ export const UNDRAINED_RESTART_EXIT_CODE = 76;
5727
+ /** Set in a daemon the supervisor started, so its own restart is an exit, not a second supervisor. */
5728
+ const SUPERVISED_ENV = 'STRATUS_SERVE_SUPERVISED';
5729
+ /**
5730
+ * The control API port the last daemon bound, handed to its replacement by
5731
+ * the supervisor. Honoured only where the replacement's own request — the
5732
+ * flag, else the config it reads afresh — is still for any free port: a
5733
+ * daemon that asked for port 0 comes back on the port it had, so a
5734
+ * dashboard page reconnects to it, while a config since edited to a fixed
5735
+ * port takes effect on that restart, as an `api` block change is meant to.
5736
+ * A hint, never a pin: passing the port back as `--api-port` would outrank
5737
+ * the config for every restart after.
5738
+ */
5739
+ const BOUND_API_PORT_ENV = 'STRATUS_SERVE_BOUND_API_PORT';
5740
+ const isDashboardSession = (value) => typeof value === 'object' && value !== null
5741
+ && typeof value.id === 'string'
5742
+ && typeof value.expiresAt === 'number'
5743
+ && typeof value.vouchedBy === 'string';
5744
+ /** Shape-checked, because the other end of an IPC channel is still another process. */
5745
+ const isSupervisorMessage = (value) => {
5746
+ if (typeof value !== 'object' || value === null) {
5747
+ return false;
5748
+ }
5749
+ const message = value;
5750
+ if (message.type === 'stratusd.bound-api-port') {
5751
+ return typeof message.port === 'number' && Number.isInteger(message.port) && message.port > 0;
5752
+ }
5753
+ if (message.type === 'stratusd.sessions') {
5754
+ return Array.isArray(message.sessions) && message.sessions.every(isDashboardSession);
5755
+ }
5756
+ return false;
5757
+ };
5758
+ /**
5759
+ * The IPC channel a supervisor opens when it spawns a daemon: what a
5760
+ * supervised daemon says up it (the port it bound, so the supervisor
5761
+ * carries *that* port to the next daemon rather than the first one's; the
5762
+ * dashboard sessions it hands on) and what comes down it. Where there is
5763
+ * no channel — the first daemon, or one a service manager started — there
5764
+ * is no one to say it to, and nothing is said.
5765
+ */
5766
+ const defaultSupervisorLink = {
5767
+ send(message) {
5768
+ if (typeof process.send === 'function' && process.channel) {
5769
+ process.send(message);
5770
+ }
5771
+ },
5772
+ receive(handler) {
5773
+ const channel = process.channel;
5774
+ if (!channel) {
5775
+ return;
5776
+ }
5777
+ process.on('message', (message) => {
5778
+ if (isSupervisorMessage(message)) {
5779
+ handler(message);
5780
+ }
5781
+ });
5782
+ // A 'message' listener refs the channel, which would keep this process
5783
+ // alive after its drain; the daemon's own work is what holds it open.
5784
+ channel.unref?.();
5785
+ },
5786
+ };
5787
+ /**
5788
+ * The parsed serve command as arguments again — what the fresh daemon is
5789
+ * started with. From the command, not from `process.argv`: `stratus
5790
+ * dashboard` runs the daemon through `runServe` with a command it built,
5791
+ * and the process's own arguments would start a second dashboard.
5792
+ */
5793
+ export const serveArgv = (command) => [
5794
+ 'serve',
5795
+ ...(command.configPath !== undefined ? ['--config', command.configPath] : []),
5796
+ ...(command.idleTimeoutMs !== undefined ? ['--idle-timeout', String(command.idleTimeoutMs / 1000)] : []),
5797
+ ...(command.approvals !== undefined ? ['--approvals', command.approvals] : []),
5798
+ ...(command.events ? [] : ['--no-events']),
5799
+ ...(command.logToFile === false ? ['--no-log-file'] : []),
5800
+ ...(command.api === false ? ['--no-api'] : command.api === true ? ['--api'] : []),
5801
+ ...(command.apiPort !== undefined ? ['--api-port', String(command.apiPort)] : []),
5802
+ ...(command.apiHost !== undefined ? ['--api-host', command.apiHost] : []),
5803
+ ];
5804
+ /**
5805
+ * The `bin` beside the module at `moduleUrl`, with that module's own
5806
+ * extension: `dist/index.js` respawns `dist/bin.js`, and a source checkout
5807
+ * running `src/index.ts` under type stripping respawns `src/bin.ts` — the
5808
+ * flags that made that possible travel in `process.execArgv`. Assuming
5809
+ * compiled output would hand a source checkout a file that does not exist,
5810
+ * and a daemon that never comes back.
5811
+ */
5812
+ export const restartEntrypoint = (moduleUrl) => {
5813
+ const modulePath = fileURLToPath(moduleUrl);
5814
+ return path.join(path.dirname(modulePath), `bin${path.extname(modulePath)}`);
5815
+ };
5816
+ /**
5817
+ * Run this CLI's own entrypoint as a child with the daemon's streams and
5818
+ * exit code, forwarding the signals a supervisor would otherwise swallow.
5819
+ *
5820
+ * A child, and the parent waits, rather than a detached process and an
5821
+ * exit: under systemd and launchd the process the manager started is the
5822
+ * service, and its exit ends the job — cgroup and all, on Linux — so the
5823
+ * daemon that received the restart has to stay the manager's process and
5824
+ * become the supervisor of the next one. That is what makes the same path
5825
+ * hold in the foreground and under `--no-login`, where nothing else would
5826
+ * bring a clean exit back.
5827
+ */
5828
+ const defaultServeRespawn = (env) => (argv, handoff) => new Promise((resolve, reject) => {
5829
+ const entrypoint = restartEntrypoint(import.meta.url);
5830
+ // The daemon's own streams, plus an IPC channel for what it and its
5831
+ // supervisor have to tell each other. See SupervisorLink.
5832
+ const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...argv], {
5833
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
5834
+ env: {
5835
+ ...process.env,
5836
+ [SUPERVISED_ENV]: '1',
5837
+ ...(handoff.boundApiPort !== undefined ? { [BOUND_API_PORT_ENV]: String(handoff.boundApiPort) } : {}),
5838
+ },
5839
+ });
5840
+ if (handoff.sessions.length > 0 && child.connected) {
5841
+ // Queued in the channel until the daemon listens, which it does
5842
+ // before its API is up; the API holds them until it can adopt them.
5843
+ // A send that fails (the daemon died before reading) is not this
5844
+ // supervisor's failure: the exit below reports what happened, and a
5845
+ // daemon that never took them simply comes up signed out.
5846
+ const sessions = { type: 'stratusd.sessions', sessions: handoff.sessions };
5847
+ child.send(sessions, () => undefined);
5848
+ }
5849
+ const forward = (signal) => () => {
5850
+ child.kill(signal);
5851
+ };
5852
+ const onTerm = forward('SIGTERM');
5853
+ const onInt = forward('SIGINT');
5854
+ const onAbort = () => {
5855
+ child.kill('SIGTERM');
5856
+ };
5857
+ process.on('SIGTERM', onTerm);
5858
+ process.on('SIGINT', onInt);
5859
+ env.shutdownSignal?.addEventListener('abort', onAbort, { once: true });
5860
+ const done = () => {
5861
+ process.off('SIGTERM', onTerm);
5862
+ process.off('SIGINT', onInt);
5863
+ env.shutdownSignal?.removeEventListener('abort', onAbort);
5864
+ };
5865
+ let reportedPort;
5866
+ let handedBack;
5867
+ child.on('message', (message) => {
5868
+ if (!isSupervisorMessage(message)) {
5869
+ return;
5870
+ }
5871
+ if (message.type === 'stratusd.bound-api-port') {
5872
+ reportedPort = message.port;
5873
+ }
5874
+ else {
5875
+ handedBack = message.sessions;
5876
+ }
5877
+ });
5878
+ child.once('error', (error) => {
5879
+ done();
5880
+ reject(error);
5881
+ });
5882
+ child.once('exit', (code, signal) => {
5883
+ done();
5884
+ // Killed by a signal is a stop, not a request to come back.
5885
+ resolve({
5886
+ code: code ?? (signal ? 1 : 0),
5887
+ ...(reportedPort !== undefined ? { boundApiPort: reportedPort } : {}),
5888
+ ...(handedBack !== undefined ? { sessions: handedBack } : {}),
5889
+ });
5890
+ });
5891
+ });
5892
+ /**
5893
+ * Keep starting daemons for as long as they exit asking for another; the
5894
+ * first exit that does not is this process's own — an undrained restart's
5895
+ * status included, which ends the supervisor rather than starting a daemon
5896
+ * beside what the last one could not release.
5897
+ */
5898
+ const superviseRestarts = async (command, first, streams, env) => {
5899
+ const respawn = env.serveRespawn ?? defaultServeRespawn(env);
5900
+ // The hand-off follows the daemons: the port each one said it bound is
5901
+ // what the next one is told (so a config edited to a fixed port and
5902
+ // back hands on the last address, not the first), and the sessions are
5903
+ // whatever the last one handed back — a daemon that handed nothing
5904
+ // hands nothing on.
5905
+ let handoff = first;
5906
+ let result;
5907
+ do {
5908
+ writeLine(streams.stdout, 'Restarting stratusd.');
5909
+ result = await respawn(serveArgv(command), handoff);
5910
+ handoff = {
5911
+ ...(result.boundApiPort !== undefined || handoff.boundApiPort !== undefined
5912
+ ? { boundApiPort: result.boundApiPort ?? handoff.boundApiPort }
5913
+ : {}),
5914
+ sessions: result.sessions ?? [],
5915
+ };
5916
+ } while (result.code === RESTART_EXIT_CODE);
5917
+ return result.code;
5918
+ };
4936
5919
  export const runServe = async (command, streams, env = {}) => {
4937
5920
  // First, before anything that can throw. A broken install makes the
4938
5921
  // gateway import below fail, the manager restarts, and the CLI's error
@@ -4945,7 +5928,46 @@ export const runServe = async (command, streams, env = {}) => {
4945
5928
  // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
4946
5929
  // command neither needs nor should pay for (Node still prints an
4947
5930
  // experimental warning for it).
4948
- const { createGateway } = await import('@stratusagent/gateway');
5931
+ const { createGateway, claimHome, HomeClaimedError } = await import('@stratusagent/gateway');
5932
+ // Before anything of this daemon's is written or opened: a second daemon
5933
+ // on this home is refused here, with no log line, no store, and no sweep
5934
+ // or firing of its own.
5935
+ let claim;
5936
+ try {
5937
+ claim = claimHome(env);
5938
+ }
5939
+ catch (error) {
5940
+ if (error instanceof HomeClaimedError) {
5941
+ throw new HomeHeldError(await describeHeldHome(env));
5942
+ }
5943
+ throw error;
5944
+ }
5945
+ let outcome;
5946
+ try {
5947
+ // The one daemon the claim cannot see: one that predates it.
5948
+ if (await legacyDaemonServing(env)) {
5949
+ throw new HomeHeldError(await describeHeldHome(env));
5950
+ }
5951
+ outcome = await serveHeldHome(command, streams, env, createGateway);
5952
+ }
5953
+ finally {
5954
+ // After everything: the store closed by stop(), or whatever a throw
5955
+ // reached — a preflight that rejected before the gateway existed
5956
+ // included, so a host that calls runServe again is not refused for a
5957
+ // daemon that never started. And never sooner, so a replacement cannot
5958
+ // open the store while this one still writes.
5959
+ claim.release();
5960
+ }
5961
+ // An announced restart, once the home is let go — the replacement claims
5962
+ // it next. A daemon the supervisor started asks it for the next one by
5963
+ // exiting; the first daemon becomes the supervisor itself. Either way the
5964
+ // process the service manager (or the terminal) started is what stays.
5965
+ if (outcome.code === RESTART_EXIT_CODE && !readProcessEnv(env)[SUPERVISED_ENV]) {
5966
+ return superviseRestarts(command, { ...(outcome.boundApiPort !== undefined ? { boundApiPort: outcome.boundApiPort } : {}), sessions: outcome.sessions }, streams, env);
5967
+ }
5968
+ return outcome.code;
5969
+ };
5970
+ const serveHeldHome = async (command, streams, env, createGateway) => {
4949
5971
  // Under a service manager the daemon's stdout is gone, so everything it
4950
5972
  // says is also written to ~/.stratus/logs — that file is what `stratus
4951
5973
  // logs` reads, and the only record of an overnight run.
@@ -4984,6 +6006,8 @@ export const runServe = async (command, streams, env = {}) => {
4984
6006
  // Typed as the seam, not as whatever the first push happens to be: the
4985
6007
  // list holds the control API and the Slack adapter alike.
4986
6008
  const controlApiChannels = [];
6009
+ /** The API adapter itself, for what the seam does not carry: its address, and the sessions it hands across a restart. */
6010
+ let boundApi;
4987
6011
  if (apiWanted) {
4988
6012
  const createControlApi = await loadControlApi();
4989
6013
  // Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
@@ -4992,16 +6016,23 @@ export const runServe = async (command, streams, env = {}) => {
4992
6016
  // Everything still bound and every test still passed, on whichever port
4993
6017
  // nobody happened to be using.
4994
6018
  const apiHost = command.apiHost ?? apiConfig.host;
4995
- const apiPort = command.apiPort ?? apiConfig.port;
6019
+ const requestedPort = command.apiPort ?? apiConfig.port;
6020
+ // The port the last daemon bound, from the supervisor that started
6021
+ // this one — taken only where this daemon's own request is still for
6022
+ // any free port. See BOUND_API_PORT_ENV.
6023
+ const hinted = Number(readProcessEnv(env)[BOUND_API_PORT_ENV]);
6024
+ const apiPort = requestedPort === 0 && Number.isInteger(hinted) && hinted > 0 ? hinted : requestedPort;
4996
6025
  if (createControlApi) {
4997
- controlApiChannels.push(createControlApi({
6026
+ const api = createControlApi({
4998
6027
  env,
4999
6028
  ...(apiHost !== undefined ? { host: apiHost } : {}),
5000
6029
  ...(apiPort !== undefined ? { port: apiPort } : {}),
5001
6030
  ...(command.configPath ? { configPath: command.configPath } : {}),
5002
6031
  log,
5003
6032
  warn,
5004
- }));
6033
+ });
6034
+ boundApi = api;
6035
+ controlApiChannels.push(api);
5005
6036
  }
5006
6037
  else if (command.api === true || apiConfig.enabled === true) {
5007
6038
  // Only when someone asked for it explicitly. A daemon that was never
@@ -5010,6 +6041,18 @@ export const runServe = async (command, streams, env = {}) => {
5010
6041
  + 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
5011
6042
  }
5012
6043
  }
6044
+ // Listening before anything else is awaited: what the supervisor hands
6045
+ // this daemon is queued in the channel until then, and the API keeps it
6046
+ // until it is serving.
6047
+ const supervised = Boolean(readProcessEnv(env)[SUPERVISED_ENV]);
6048
+ const link = env.supervisorLink ?? defaultSupervisorLink;
6049
+ if (supervised) {
6050
+ link.receive((message) => {
6051
+ if (message.type === 'stratusd.sessions') {
6052
+ boundApi?.adoptSessions(message.sessions);
6053
+ }
6054
+ });
6055
+ }
5013
6056
  const channelCredentials = await loadChannelCredentials(env);
5014
6057
  const slackAgents = Object.entries(channelCredentials.slack ?? {});
5015
6058
  const channels = [...controlApiChannels];
@@ -5107,7 +6150,10 @@ export const runServe = async (command, streams, env = {}) => {
5107
6150
  // a tool that carries no command string is judged by its risk exactly as
5108
6151
  // before. The whitelist lives beside the agent's soul, per agent.
5109
6152
  const commands = {
5110
- whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env) }),
6153
+ // A whitelist that exists and will not read is said here, once, and
6154
+ // never written over — the daemon's log is where a grant list going
6155
+ // quiet would otherwise go unnoticed.
6156
+ whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env), warn }),
5111
6157
  onScopeRemembered: ({ agentId, scope }) => {
5112
6158
  // An approval that widens what runs unattended, for every future
5113
6159
  // session, is precisely the decision that must not be the one leaving
@@ -5115,14 +6161,24 @@ export const runServe = async (command, streams, env = {}) => {
5115
6161
  log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
5116
6162
  },
5117
6163
  };
6164
+ // The origin-scope engine, whose grants live in the same file as the
6165
+ // command scopes: one place an operator looks to see what an agent may
6166
+ // do unattended. Wired unconditionally for the same reason — a tool that
6167
+ // names no origin is judged by its risk exactly as before.
6168
+ const origins = {
6169
+ whitelist: commands.whitelist,
6170
+ onScopeRemembered: ({ agentId, scope }) => {
6171
+ log(`${agentId}: ${describeOriginScope(scope)} is now acted on without asking`);
6172
+ },
6173
+ };
5118
6174
  const approvals = (transport) => createPermissionPolicy(
5119
6175
  // The destination scope rides along in BOTH modes — it is what lets a
5120
6176
  // scheduled turn report to the channel a human approved with the
5121
6177
  // schedule, and headless (where every other gated call is refused) is
5122
6178
  // exactly the deployment it exists for.
5123
6179
  approvalMode === 'remote'
5124
- ? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
5125
- : { mode: 'headless', onDecision, commands, destinations: transport.destinations });
6180
+ ? { mode: 'remote', request: transport.request, onDecision, commands, origins, destinations: transport.destinations }
6181
+ : { mode: 'headless', onDecision, commands, origins, destinations: transport.destinations });
5126
6182
  if (approvalMode === 'remote') {
5127
6183
  // Only agents whose channel actually came up can be asked: tokens on
5128
6184
  // disk with the Slack package missing means nothing renders the
@@ -5130,9 +6186,30 @@ export const runServe = async (command, streams, env = {}) => {
5130
6186
  const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
5131
6187
  log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
5132
6188
  }
6189
+ /**
6190
+ * The one signal handler this daemon has, for the whole of its life —
6191
+ * installed by the wait below, removed in the finally after the drain.
6192
+ * See the wait for why it is never `once` and never removed sooner.
6193
+ */
6194
+ let stopSignal;
6195
+ let repeatWarned = false;
6196
+ // The gateway's restart hands the process back here once it has stopped:
6197
+ // the outcome is kept and the wait below released, and the tail of this
6198
+ // function decides what "come back" means for this process.
6199
+ let restart;
6200
+ /** A signal or the host's abort asked for a stop — set when the wait ends for that reason. */
6201
+ let stopRequested = false;
6202
+ let requestShutdown = () => { };
6203
+ const shutdownRequested = new Promise((resolve) => {
6204
+ requestShutdown = resolve;
6205
+ });
5133
6206
  const gateway = createGateway({
5134
6207
  env,
5135
6208
  approvals,
6209
+ onRestart: (outcome) => {
6210
+ restart = outcome;
6211
+ requestShutdown();
6212
+ },
5136
6213
  ...(Object.keys(pluginsConfig).length > 0
5137
6214
  ? {
5138
6215
  plugins: pluginsConfig,
@@ -5214,36 +6291,46 @@ export const runServe = async (command, streams, env = {}) => {
5214
6291
  });
5215
6292
  }
5216
6293
  await gateway.start();
5217
- // The roster is only known once the gateway has loaded it, and in remote
5218
- // mode an agent no channel can ask for is the quietest failure this
5219
- // feature has: its gated calls park with nobody rendering them and wait
5220
- // out the whole timeout before being denied. No channel can detect this
5221
- // on its own — a request is a broadcast, and no adapter knows whether
5222
- // another one is about to answer — so it is reported here, where the
5223
- // roster and the channel list are both in view.
5224
- if (approvalMode === 'remote') {
5225
- const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
5226
- const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
5227
- if (unreachable.length > 0) {
5228
- warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
5229
- + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
5230
- }
5231
- }
5232
- writeLine(streams.stdout, 'Press Ctrl+C to stop.');
5233
- // And periodically, for a long-running daemon that warns steadily
5234
- // without ever writing enough records to rotate. Unref'd, so it never
5235
- // holds the process open. Armed only once the daemon is actually
5236
- // serving: every step above can throw, and runServe is an exported
5237
- // function as much as a process entry point — in a host that survives
5238
- // the failure, a timer armed before the throw would outlive the call
5239
- // and go on truncating that environment's redirect logs. The finally
5240
- // below takes it down on every other exit path.
6294
+ // Read now, while the API is bound: its stop forgets the address, and
6295
+ // a restart's respawn needs it (see ServeOutcome).
6296
+ const boundApiPort = boundApi?.url !== undefined ? Number(new URL(boundApi.url).port) : undefined;
6297
+ if (supervised && boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0) {
6298
+ link.send({ type: 'stratusd.bound-api-port', port: boundApiPort });
6299
+ }
6300
+ // Under one finally from here: a throw anywhere after the start — a
6301
+ // host's writable refusing a line, say — must still stop the gateway
6302
+ // before the claim on the home is released, or a live gateway would be
6303
+ // left behind a lock the next daemon can take.
5241
6304
  let redirectTimer;
5242
- if (logWriter) {
5243
- redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
5244
- redirectTimer.unref?.();
5245
- }
5246
6305
  try {
6306
+ // The roster is only known once the gateway has loaded it, and in remote
6307
+ // mode an agent no channel can ask for is the quietest failure this
6308
+ // feature has: its gated calls park with nobody rendering them and wait
6309
+ // out the whole timeout before being denied. No channel can detect this
6310
+ // on its own — a request is a broadcast, and no adapter knows whether
6311
+ // another one is about to answer — so it is reported here, where the
6312
+ // roster and the channel list are both in view.
6313
+ if (approvalMode === 'remote') {
6314
+ const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
6315
+ const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
6316
+ if (unreachable.length > 0) {
6317
+ warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
6318
+ + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
6319
+ }
6320
+ }
6321
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
6322
+ // And periodically, for a long-running daemon that warns steadily
6323
+ // without ever writing enough records to rotate. Unref'd, so it never
6324
+ // holds the process open. Armed only once the daemon is actually
6325
+ // serving: every step above can throw, and runServe is an exported
6326
+ // function as much as a process entry point — in a host that survives
6327
+ // the failure, a timer armed before the throw would outlive the call
6328
+ // and go on truncating that environment's redirect logs. The finally
6329
+ // below takes it down on every other exit path.
6330
+ if (logWriter) {
6331
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
6332
+ redirectTimer.unref?.();
6333
+ }
5247
6334
  // Hold the event loop open until a shutdown request, then drain: new
5248
6335
  // dispatches are refused while in-flight turns finish.
5249
6336
  await new Promise((resolve) => {
@@ -5255,12 +6342,46 @@ export const runServe = async (command, streams, env = {}) => {
5255
6342
  }
5256
6343
  settled = true;
5257
6344
  clearInterval(keepAlive);
5258
- process.off('SIGTERM', shutdown);
5259
- process.off('SIGINT', shutdown);
5260
6345
  resolve();
5261
6346
  };
5262
- process.once('SIGTERM', shutdown);
5263
- process.once('SIGINT', shutdown);
6347
+ stopSignal = () => {
6348
+ if (!settled) {
6349
+ shutdown();
6350
+ return;
6351
+ }
6352
+ // A signal while a drain is already running — a repeat, or a stop
6353
+ // arriving during a restart's drain. Either way the drain goes on,
6354
+ // and a daemon told to stop does not come back.
6355
+ stopRequested = true;
6356
+ if (!repeatWarned) {
6357
+ repeatWarned = true;
6358
+ // Said from signal dispatch, outside the promise and its finally:
6359
+ // a stream that throws on write (a host's injected stderr) would
6360
+ // otherwise end the process from here — mid-drain, the very
6361
+ // thing this handler exists to prevent. The word is best effort.
6362
+ try {
6363
+ warn('stop signal received while draining; still draining, and this daemon will not come back (SIGKILL ends it at once)');
6364
+ }
6365
+ catch {
6366
+ // Nothing to say it to; the drain is what matters.
6367
+ }
6368
+ }
6369
+ };
6370
+ // `on`, never `once`, and not removed until the drain is over (the
6371
+ // finally below). `once` drops the listener before running it, Node
6372
+ // uninstalls its native handler the moment no listener is left, and
6373
+ // a second SIGTERM landing in that window ends the process by the
6374
+ // default action — mid-drain, with nothing said. The second signal
6375
+ // is the normal case, not an edge: a stop delivered to the process
6376
+ // group, as systemd's KillMode=control-group and a terminal's Ctrl+C
6377
+ // deliver it, reaches a supervised daemon once from the kernel and
6378
+ // once more forwarded by its supervisor. Reproduced two runs in
6379
+ // three: the replacement died and the supervisor exited 1 on a
6380
+ // `stratus service stop` after a restart. SIGKILL still ends the
6381
+ // process at once.
6382
+ process.on('SIGTERM', stopSignal);
6383
+ process.on('SIGINT', stopSignal);
6384
+ void shutdownRequested.then(shutdown);
5264
6385
  if (env.shutdownSignal?.aborted) {
5265
6386
  shutdown();
5266
6387
  }
@@ -5268,20 +6389,85 @@ export const runServe = async (command, streams, env = {}) => {
5268
6389
  env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
5269
6390
  }
5270
6391
  });
5271
- writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
5272
- await gateway.stop();
5273
- return 0;
6392
+ // Decided now, before the stop below: a restart's drain can still be
6393
+ // running when a stop signal releases this wait, and `onRestart` then
6394
+ // fires during that stop. A daemon told to stop does not come back,
6395
+ // whatever it was told before.
6396
+ if (!restart) {
6397
+ stopRequested = true;
6398
+ }
6399
+ if (restart) {
6400
+ // Already stopped: the gateway drained and closed before handing the
6401
+ // process over, and the stop() below finds nothing to do. Said here,
6402
+ // while the structured log still takes writes — the next lines
6403
+ // belong to a process that has none.
6404
+ if (!restart.drained) {
6405
+ // A turn that ignored its abort is still running in this process,
6406
+ // possibly still writing sessions. Starting a fresh daemon beside
6407
+ // it would be two processes on one store; exiting instead leaves
6408
+ // the service manager, where there is one, to bring it back.
6409
+ warn(`restart: something did not let go; exiting with status ${UNDRAINED_RESTART_EXIT_CODE} for the service manager to restart, instead of starting a new daemon beside it`);
6410
+ }
6411
+ else {
6412
+ log(`restarting stratusd${restart.reason ? ` (${restart.reason})` : ''}`);
6413
+ }
6414
+ }
6415
+ else {
6416
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
6417
+ }
5274
6418
  }
5275
6419
  finally {
5276
- if (redirectTimer) {
5277
- clearInterval(redirectTimer);
6420
+ try {
6421
+ await gateway.stop();
6422
+ // Writes are queued and dropped on the hot path so logging never sits
6423
+ // on a streamed token. That makes the tail of the log the part most
6424
+ // likely to be lost — and the tail is the shutdown reason, the last
6425
+ // warning, the line explaining a restart.
6426
+ await logWriter?.flush();
5278
6427
  }
5279
- // Writes are queued and dropped on the hot path so logging never sits
5280
- // on a streamed token. That makes the tail of the log the part most
5281
- // likely to be lost — and the tail is the shutdown reason, the last
5282
- // warning, the line explaining a restart.
5283
- await logWriter?.flush();
5284
- }
6428
+ finally {
6429
+ // The shutdown is over, the flush included: a stop signal from here
6430
+ // on is the process's to handle by default again — and a host that
6431
+ // calls runServe repeatedly (the tests) must not accumulate handlers.
6432
+ // Last, and under its own finally, because the listener is on the
6433
+ // global process: taken off before the flush, a repeated signal
6434
+ // landing during it would end the process by the default action
6435
+ // (which a supervisor reports as a failure, and a service manager
6436
+ // set to restart on failure would then bring back a daemon an
6437
+ // operator just stopped); left on by a stop() that rejects (its last
6438
+ // log line can throw through an injected stream), a settled handler
6439
+ // would swallow the host's next signal.
6440
+ if (stopSignal) {
6441
+ process.off('SIGTERM', stopSignal);
6442
+ process.off('SIGINT', stopSignal);
6443
+ }
6444
+ if (redirectTimer) {
6445
+ clearInterval(redirectTimer);
6446
+ }
6447
+ }
6448
+ }
6449
+ const bound = boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0
6450
+ ? { boundApiPort }
6451
+ : {};
6452
+ if (!restart || stopRequested) {
6453
+ return { code: 0, ...bound, sessions: [] };
6454
+ }
6455
+ if (!restart.drained) {
6456
+ (env.exitProcess ?? process.exit)(UNDRAINED_RESTART_EXIT_CODE);
6457
+ return { code: UNDRAINED_RESTART_EXIT_CODE, ...bound, sessions: [] };
6458
+ }
6459
+ // Coming back: the browser sessions live at the stop go to the
6460
+ // replacement — up the channel to the supervisor when this daemon has
6461
+ // one, else with the outcome to the supervisor this process becomes.
6462
+ const sessions = boundApi?.sessionsAtStop() ?? [];
6463
+ if (supervised && sessions.length > 0) {
6464
+ link.send({ type: 'stratusd.sessions', sessions });
6465
+ }
6466
+ // Asked to come back. Whether this process starts the next daemon or
6467
+ // exits for its supervisor to is `runServe`'s decision, after the home
6468
+ // is released: the claim is held until this returns, and a daemon
6469
+ // started before that would be refused as a second one on the home.
6470
+ return { code: RESTART_EXIT_CODE, ...bound, sessions };
5285
6471
  };
5286
6472
  export const runCli = async ({ argv, streams = process, env = {} }) => {
5287
6473
  try {
@@ -5320,6 +6506,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
5320
6506
  || command.command === 'run'
5321
6507
  || command.command === 'skill-add'
5322
6508
  || command.command === 'dashboard'
6509
+ || (command.command === 'credential' && command.action !== 'list')
5323
6510
  || (command.command === 'schedules' && command.action === 'cancel')
5324
6511
  || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
5325
6512
  if (writesState) {
@@ -5359,9 +6546,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
5359
6546
  if (command.command === 'skill-add') {
5360
6547
  return await runSkillAdd(command, streams, resolvedEnv);
5361
6548
  }
6549
+ if (command.command === 'skill-validate') {
6550
+ return await runSkillValidate(command, streams, resolvedEnv);
6551
+ }
5362
6552
  if (command.command === 'skills') {
5363
6553
  return await runSkills(streams, resolvedEnv);
5364
6554
  }
6555
+ if (command.command === 'credential') {
6556
+ return await runCredential(command, streams, resolvedEnv);
6557
+ }
6558
+ if (command.command === 'skill-reload') {
6559
+ return await runSkillReload(command, streams, resolvedEnv);
6560
+ }
6561
+ if (command.command === 'restart') {
6562
+ return await runRestart(command, streams, resolvedEnv);
6563
+ }
5365
6564
  if (command.command === 'schedules') {
5366
6565
  return await runSchedules(command, streams, resolvedEnv);
5367
6566
  }