@stratusagent/cli 0.10.0 → 0.11.1

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, describeToolAllowlistFinding, unmatchedToolAllowlist, 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, GATEWAY_ONLY_TOOL_NAMES, } 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.10.0';
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.1';
127
137
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
128
138
  /**
129
139
  * The range every package declares in its `engines` field. Two floors, not
@@ -243,9 +253,14 @@ Usage:
243
253
  stratus agents --gateway http://127.0.0.1:4123
244
254
  stratus skill add stratuslabs/skill-code-review
245
255
  stratus skill add ./my-skills --skill code-review --agent ava
256
+ stratus skill validate ./my-skill
246
257
  stratus skills
258
+ stratus skill reload
259
+ stratus restart
247
260
  stratus schedules
248
261
  stratus schedules cancel <id>
262
+ printf %s "$BRAVE_KEY" | stratus credential set search.apiKey
263
+ stratus credentials
249
264
  stratus doctor
250
265
  stratus update
251
266
  stratus update --check
@@ -269,7 +284,9 @@ Commands:
269
284
  run Execute one local Stratus Agent session
270
285
  serve Run stratusd, the always-on gateway: durable sessions, the
271
286
  whole roster live at once (each agent on its own provider),
272
- 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
273
290
  (--idle-timeout <seconds>, --approvals <headless|remote>,
274
291
  --no-events, --no-log-file, --config <path>); everything it
275
292
  says is also written to ~/.stratus/logs, which
@@ -291,9 +308,39 @@ Commands:
291
308
  (skills.sh-style repos). Installed is not enabled: a soul
292
309
  opts in via skills:, or pass --agent <id> to enable now.
293
310
  --skill <id> picks from a multi-skill repo (repeatable),
294
- --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
295
321
  skills List installed skills and which agents enable each
296
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)
297
344
  schedules List every schedule the fleet has set — cadence, prompt,
298
345
  pre-authorized destination, next firing — straight from the
299
346
  daemon's database (--format json). "stratus schedules
@@ -345,9 +392,16 @@ Options:
345
392
  --port dashboard: port for a daemon it starts (default: 4123)
346
393
  --host dashboard: host for a daemon it starts (default: 127.0.0.1)
347
394
  --no-open Do not open the browser automatically
348
- --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)
349
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)
350
403
  --no-api serve: do not serve the control API
404
+ --api serve: serve it even where the config says api.enabled: false
351
405
  --api-host serve: control API interface (default: 127.0.0.1)
352
406
  --api-port serve: control API port (default: 4123, 0 for any free port)
353
407
  --help, -h Show this help message
@@ -401,6 +455,23 @@ const readPromptFromStdin = async (stdin) => {
401
455
  }
402
456
  return data.trim();
403
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
+ };
404
475
  const readOptionValue = (tokens, index, flag) => {
405
476
  const value = tokens[index + 1];
406
477
  if (!value) {
@@ -497,6 +568,10 @@ export const parseCommand = (argv, env = {}) => {
497
568
  parsed.api = false;
498
569
  continue;
499
570
  }
571
+ if (token === '--api') {
572
+ parsed.api = true;
573
+ continue;
574
+ }
500
575
  if (token === '--api-port') {
501
576
  const port = Number(readOptionValue(rest, index, '--api-port'));
502
577
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
@@ -802,6 +877,63 @@ export const parseCommand = (argv, env = {}) => {
802
877
  ...(sessionId ? { sessionId } : {}),
803
878
  };
804
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
+ }
805
937
  if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
806
938
  const skillsRest = command === 'skills' ? rest : rest.slice(1);
807
939
  for (const token of skillsRest) {
@@ -817,13 +949,57 @@ export const parseCommand = (argv, env = {}) => {
817
949
  if (subcommand === '--help' || subcommand === '-h') {
818
950
  return { command: 'help' };
819
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
+ }
820
995
  if (subcommand !== 'add') {
821
- 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`);
822
997
  }
823
998
  let source;
824
999
  const skillIds = [];
825
1000
  let force = false;
826
1001
  let agentId;
1002
+ let reload = true;
827
1003
  for (let index = 0; index < skillRest.length; index += 1) {
828
1004
  const token = skillRest[index];
829
1005
  if (!token) {
@@ -846,6 +1022,10 @@ export const parseCommand = (argv, env = {}) => {
846
1022
  force = true;
847
1023
  continue;
848
1024
  }
1025
+ if (token === '--no-reload') {
1026
+ reload = false;
1027
+ continue;
1028
+ }
849
1029
  if (token.startsWith('--')) {
850
1030
  throw new Error(`Unknown option: ${token}`);
851
1031
  }
@@ -863,8 +1043,47 @@ export const parseCommand = (argv, env = {}) => {
863
1043
  ...(skillIds.length > 0 ? { skillIds } : {}),
864
1044
  ...(force ? { force } : {}),
865
1045
  ...(agentId !== undefined ? { agentId } : {}),
1046
+ ...(reload ? {} : { reload }),
866
1047
  };
867
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
+ }
868
1087
  if (command === 'agent') {
869
1088
  const [subcommand, ...agentRest] = rest;
870
1089
  if (subcommand === '--help' || subcommand === '-h') {
@@ -1184,7 +1403,56 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
1184
1403
  + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
1185
1404
  }
1186
1405
  };
1187
- 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) => {
1188
1456
  if (mode === 'always') {
1189
1457
  return new AllowAllApprovalPolicy();
1190
1458
  }
@@ -1199,25 +1467,27 @@ const createApprovalPolicy = (mode, streams, env, ask) => {
1199
1467
  // asker — two readers on one stream would race for the same bytes.
1200
1468
  if (ask) {
1201
1469
  return {
1202
- async approve({ call }) {
1203
- const answer = await ask(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
1204
- 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);
1205
1474
  },
1206
1475
  };
1207
1476
  }
1208
1477
  return {
1209
- async approve({ call }) {
1478
+ async approve(context) {
1210
1479
  const input = env.approvalInput ?? process.stdin;
1211
1480
  const readline = createInterface({ input, terminal: false });
1481
+ const approved = approvalOrigin(context);
1212
1482
  // Prompt on stderr so stdout stays parseable (e.g. --format json).
1213
- 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] `);
1214
1484
  try {
1215
1485
  const answer = await new Promise((resolve) => {
1216
1486
  readline.once('line', resolve);
1217
1487
  readline.once('close', () => resolve(''));
1218
1488
  });
1219
1489
  writeLine(streams.stderr);
1220
- return /^y(es)?$/i.test(answer.trim());
1490
+ return /^y(es)?$/i.test(answer.trim()) && stillOnApprovedPage(context, approved, streams);
1221
1491
  }
1222
1492
  finally {
1223
1493
  readline.close();
@@ -1284,6 +1554,7 @@ const createAgentRuntime = async (streams, options) => {
1284
1554
  tools,
1285
1555
  skills,
1286
1556
  bus,
1557
+ credentials: createFileCredentialResolver(runEnv),
1287
1558
  workspaceRoot: workspacesDirPath(runEnv),
1288
1559
  });
1289
1560
  loadedPlugins.push(...result.loaded);
@@ -1326,6 +1597,26 @@ const createAgentRuntime = async (streams, options) => {
1326
1597
  for (const { skill, missing } of missingSkillRequirements(agent, skills)) {
1327
1598
  writeLine(streams.stderr, `Warning: agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
1328
1599
  }
1600
+ // And the same for the tools themselves, after `initialize()` so the
1601
+ // plugins have registered theirs: an allowlist entry naming a tool
1602
+ // nothing provides grants nothing and says nothing, and a soul made only
1603
+ // of those runs with no tools while its persona still talks about them.
1604
+ const finding = unmatchedToolAllowlist(agent, tools.describe().map((tool) => tool.name), {
1605
+ mayRegister: loadedPlugins.flatMap((plugin) => plugin.manifest.contributes.toolsDiscovered.map((declared) => declared.namespace)),
1606
+ // A local run has no dispatcher, store, or channels, so it registers
1607
+ // none of the daemon's tools. A soul written for `stratus serve` is
1608
+ // not wrong for naming them here — it is in the wrong process.
1609
+ elsewhere: GATEWAY_ONLY_TOOL_NAMES,
1610
+ });
1611
+ for (const line of finding ? describeToolAllowlistFinding(agent.id, finding) : []) {
1612
+ writeLine(streams.stderr, `Warning: ${line}`);
1613
+ }
1614
+ // The kernel leaves this one to the host, because only the host knows
1615
+ // which process it is not.
1616
+ if (finding && finding.elsewhere.length > 0) {
1617
+ writeLine(streams.stderr, `Warning: agent ${agent.id} lists ${finding.elsewhere.join(', ')}, which only the daemon provides`
1618
+ + ' — the names are right, but stratus run cannot call them; stratus serve can');
1619
+ }
1329
1620
  const metadata = options.runtime.provider === 'demo'
1330
1621
  ? { provider: 'demo', executor: 'local-command' }
1331
1622
  : {
@@ -1988,20 +2279,28 @@ const verifySlackAppToken = async (token, fetchImpl) => {
1988
2279
  // package being installed.
1989
2280
  const SLACK_BOT_SCOPES = [
1990
2281
  'app_mentions:read',
2282
+ // The history family is what lets an agent stay in a conversation it was
2283
+ // brought into: without it Slack delivers only mentions, and a reply in
2284
+ // the agent's own thread never reaches it. It is the workspace's switch
2285
+ // for that — an app installed without these scopes answers mentions and
2286
+ // nothing else, exactly as it always did.
2287
+ 'channels:history',
1991
2288
  // The conversations read family backs outbound destination validation
1992
2289
  // (conversations.info): whether a channel a schedule wants to report to
1993
2290
  // exists, and whether this app is a member of it.
1994
2291
  'channels:read',
1995
2292
  'chat:write',
1996
2293
  'files:write',
2294
+ 'groups:history',
1997
2295
  'groups:read',
1998
2296
  'im:history',
1999
2297
  'im:read',
2000
2298
  'im:write',
2299
+ 'mpim:history',
2001
2300
  'mpim:read',
2002
2301
  'users:read',
2003
2302
  ];
2004
- const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
2303
+ const SLACK_BOT_EVENTS = ['app_mention', 'message.channels', 'message.groups', 'message.im', 'message.mpim'];
2005
2304
  export const slackAppManifest = (agentName) => JSON.stringify({
2006
2305
  display_information: {
2007
2306
  name: agentName,
@@ -3150,9 +3449,10 @@ export const runSetup = async (command, streams, env = {}) => {
3150
3449
  if (chosen.length > 0) {
3151
3450
  const packages = chosen.flatMap((group) => group.packages);
3152
3451
  writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
3153
- // Never fails setup, for the same reason the service install does not:
3154
- // the config and credentials are already written, and a package that
3155
- // did not install is a warning at the next start, not a broken machine.
3452
+ // Never fails setup — not even its exit code, unlike the always-on
3453
+ // service step below: the config and credentials are already written,
3454
+ // and a package that did not install is a warning at the next start,
3455
+ // not a broken machine.
3156
3456
  const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
3157
3457
  .catch((error) => ({
3158
3458
  ok: false,
@@ -3181,20 +3481,24 @@ export const runSetup = async (command, streams, env = {}) => {
3181
3481
  };
3182
3482
  /**
3183
3483
  * Write everything the menu decided, then report whether the always-on
3184
- * install it performed succeeded.
3484
+ * service step it performed — installing the service, or removing one the
3485
+ * user chose not to run — succeeded.
3185
3486
  *
3186
- * A boolean rather than a throw: the install is the one optional part of
3487
+ * A boolean rather than a throw: the service is the one optional part of
3187
3488
  * setup and everything else is already on disk by the time it runs, so
3188
3489
  * failing outright would lose the saved config to a service that can be
3189
- * installed later. The exit code carries it instead — the same answer
3190
- * `stratus service install` gives for the identical failure, which is what
3191
- * a script driving setup has to be able to see.
3490
+ * installed (or removed) later. The exit code carries it instead — the
3491
+ * same answer `stratus service install` and `stratus service uninstall`
3492
+ * give for the identical failure, which is what a script driving setup
3493
+ * has to be able to see.
3192
3494
  */
3193
3495
  const save = async () => {
3194
- // The always-on install is the only step here that can fail without
3496
+ // The always-on service step is the only one here that can fail without
3195
3497
  // taking the rest of setup with it, so it is the only one the exit code
3196
- // has to carry.
3197
- let serviceInstallFailed = false;
3498
+ // has to carry. Both signs count: a unit left in place after "do not run
3499
+ // it for me" starts a daemon at login the user asked not to have, which
3500
+ // is no more a successful setup than one that will not come up at all.
3501
+ let serviceStepFailed = false;
3198
3502
  const config = { provider: state.provider };
3199
3503
  if (state.provider !== 'demo') {
3200
3504
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -3287,6 +3591,9 @@ export const runSetup = async (command, streams, env = {}) => {
3287
3591
  for (const message of removed.messages) {
3288
3592
  writeLine(removed.ok ? streams.stdout : streams.stderr, message);
3289
3593
  }
3594
+ if (!removed.ok) {
3595
+ serviceStepFailed = true;
3596
+ }
3290
3597
  }
3291
3598
  }
3292
3599
  else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
@@ -3312,7 +3619,7 @@ export const runSetup = async (command, streams, env = {}) => {
3312
3619
  }
3313
3620
  if (!result.ok) {
3314
3621
  writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
3315
- serviceInstallFailed = true;
3622
+ serviceStepFailed = true;
3316
3623
  }
3317
3624
  }
3318
3625
  writeLine(streams.stdout);
@@ -3363,7 +3670,7 @@ export const runSetup = async (command, streams, env = {}) => {
3363
3670
  if (dashboardReady) {
3364
3671
  writeLine(streams.stdout, ' stratus dashboard');
3365
3672
  }
3366
- return !serviceInstallFailed;
3673
+ return !serviceStepFailed;
3367
3674
  };
3368
3675
  try {
3369
3676
  if (!interactive) {
@@ -3407,11 +3714,12 @@ export const runSetup = async (command, streams, env = {}) => {
3407
3714
  await testRun();
3408
3715
  }
3409
3716
  }
3410
- // Non-zero when the always-on install failed, matching `stratus service
3411
- // install`. Everything setup saved is still saved, and the output above
3412
- // says so — but a daemon that will not come up at login is not a
3413
- // successful setup, and a script that only reads the exit code had no
3414
- // way to tell.
3717
+ // Non-zero when the always-on service step failed, matching `stratus
3718
+ // service install` and `stratus service uninstall`. Everything setup
3719
+ // saved is still saved, and the output above says so — but a daemon that
3720
+ // will not come up at login, or one that will after the user said not to
3721
+ // run it, is not a successful setup, and a script that only reads the
3722
+ // exit code had no way to tell.
3415
3723
  return (await save()) ? 0 : 1;
3416
3724
  }
3417
3725
  finally {
@@ -4461,6 +4769,18 @@ export const runSkillAdd = async (command, streams, env = {}) => {
4461
4769
  });
4462
4770
  for (const skill of result.installed) {
4463
4771
  writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
4772
+ // The spec's compatibility field is written for exactly this reader:
4773
+ // the person deciding whether this environment can carry the skill.
4774
+ if (skill.compatibility !== undefined) {
4775
+ writeLine(streams.stdout, ` compatibility: ${skill.compatibility}`);
4776
+ }
4777
+ }
4778
+ // What installed with a caveat — a field another host owns, the
4779
+ // legacy key form, a bundled scripts/ — is said next to the install,
4780
+ // per skill: the operator deciding to enable it is the one who needs
4781
+ // to hear it, and this is the moment they are looking.
4782
+ for (const warning of result.warnings) {
4783
+ writeLine(streams.stderr, `Warning: ${warning.id}: ${warning.message}`);
4464
4784
  }
4465
4785
  // Present already is a no-op, not a failure — and exactly what the
4466
4786
  // "rerun with --agent" hint below produces, so these stay eligible
@@ -4477,6 +4797,13 @@ export const runSkillAdd = async (command, streams, env = {}) => {
4477
4797
  : `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
4478
4798
  return 1;
4479
4799
  }
4800
+ // The ordinary path needs no second step: a daemon that is running
4801
+ // serves what was just installed from its next turn. Only when
4802
+ // something changed on disk — a re-run that installed nothing has
4803
+ // nothing to reload — and only where a daemon has said it is running.
4804
+ if (result.installed.length > 0 && command.reload !== false) {
4805
+ await reloadRunningDaemonSkills(streams, env);
4806
+ }
4480
4807
  // Installed is not enabled: a soul opts in through its skills:
4481
4808
  // allowlist, and that stays true when the install came through a
4482
4809
  // command. --agent is the explicit way to say both at once.
@@ -4564,6 +4891,279 @@ export const runSchedules = async (command, streams, env = {}) => {
4564
4891
  store.close();
4565
4892
  }
4566
4893
  };
4894
+ /**
4895
+ * `stratus credential set|list|remove`: the place a named credential goes.
4896
+ *
4897
+ * A credential nobody can add is a credential nobody has, which is why this
4898
+ * exists at all — a search backend asks for `search.apiKey` and until now
4899
+ * there was nowhere to put one. Three rules it keeps, all of them
4900
+ * deliberate:
4901
+ *
4902
+ * The value is read from **stdin, never from a flag**. A secret in argv is
4903
+ * a secret in shell history and in every `ps` on the machine.
4904
+ *
4905
+ * Nothing here ever prints a value back. `list` reports names and which
4906
+ * agents have their own, the same posture the control API's credential read
4907
+ * already keeps.
4908
+ *
4909
+ * And storing one grants no agent anything: the agent's soul still has to
4910
+ * list the name under `credentials:`, which is the per-identity gate.
4911
+ */
4912
+ export const runCredential = async (command, streams, env = {}) => {
4913
+ const named = await loadNamedCredentials(env);
4914
+ if (command.action === 'list') {
4915
+ const shared = Object.keys(named.shared).sort();
4916
+ const agents = Object.entries(named.agents).sort(([a], [b]) => a.localeCompare(b));
4917
+ if (shared.length === 0 && agents.length === 0) {
4918
+ writeLine(streams.stdout, `No named credentials stored in ${credentialsPath(env)}.`);
4919
+ writeLine(streams.stdout, 'A search backend wants `stratus credential set search.apiKey`.');
4920
+ return 0;
4921
+ }
4922
+ if (shared.length > 0) {
4923
+ writeLine(streams.stdout, 'Shared with the whole fleet:');
4924
+ for (const name of shared) {
4925
+ writeLine(streams.stdout, ` ${name}`);
4926
+ }
4927
+ }
4928
+ for (const [agentId, entries] of agents) {
4929
+ writeLine(streams.stdout, `Only ${agentId}:`);
4930
+ for (const name of Object.keys(entries).sort()) {
4931
+ writeLine(streams.stdout, ` ${name}`);
4932
+ }
4933
+ }
4934
+ // Names, never values — and say so, so nobody goes looking for a flag
4935
+ // that prints one.
4936
+ writeLine(streams.stdout, '');
4937
+ writeLine(streams.stdout, `Values are never printed. They live in ${credentialsPath(env)}, readable only by you.`);
4938
+ return 0;
4939
+ }
4940
+ const name = command.name ?? '';
4941
+ if (command.action === 'remove') {
4942
+ const scope = command.agentId;
4943
+ const store = scope === undefined ? named.shared : named.agents[scope];
4944
+ if (!store || store[name] === undefined) {
4945
+ writeLine(streams.stderr, scope === undefined
4946
+ ? `No shared credential named ${name}. \`stratus credentials\` lists what is stored.`
4947
+ : `Agent ${scope} has no credential of its own named ${name}. \`stratus credentials\` lists what is stored.`);
4948
+ return 1;
4949
+ }
4950
+ delete store[name];
4951
+ if (scope !== undefined && Object.keys(store).length === 0) {
4952
+ delete named.agents[scope];
4953
+ }
4954
+ await saveNamedCredentials(env, named);
4955
+ writeLine(streams.stdout, scope === undefined ? `Removed ${name}.` : `Removed ${name} for ${scope}.`);
4956
+ return 0;
4957
+ }
4958
+ 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.`);
4959
+ const arrived = env.stdin ?? await readSecretFromStdin(env.stdinStream ?? process.stdin);
4960
+ // One trailing line terminator and nothing else. `echo "$KEY" |` appends a
4961
+ // newline that is not part of the key and `printf %s` appends nothing, so
4962
+ // both spellings have to work — while a key whose own value ends in a
4963
+ // space must survive either of them, which a blanket trim would not allow.
4964
+ const value = arrived.replace(/\r?\n$/, '');
4965
+ if (value.trim().length === 0) {
4966
+ writeLine(streams.stderr, `Nothing arrived on stdin, so ${name} was not stored. Pipe the value in: \`printf %s "$KEY" | stratus credential set ${name}\`.`);
4967
+ return 1;
4968
+ }
4969
+ if (command.agentId === undefined) {
4970
+ named.shared[name] = value;
4971
+ }
4972
+ else {
4973
+ named.agents[command.agentId] = { ...(named.agents[command.agentId] ?? {}), [name]: value };
4974
+ }
4975
+ await saveNamedCredentials(env, named);
4976
+ writeLine(streams.stdout, command.agentId === undefined
4977
+ ? `Stored ${name} in ${credentialsPath(env)} (readable only by you).`
4978
+ : `Stored ${name} for ${command.agentId} in ${credentialsPath(env)} (readable only by you). It outranks the shared entry for that agent.`);
4979
+ // Storing grants nothing: the soul is the second gate, and an operator
4980
+ // who stops here gets "not allowed to access credential" on every call.
4981
+ writeLine(streams.stdout, `Each agent that may use it still needs \`credentials: [${name}]\` in its soul.`);
4982
+ return 0;
4983
+ };
4984
+ /**
4985
+ * `stratus skill validate <target>`: the install-time check, run without
4986
+ * installing — for an author about to publish a skill, or an operator
4987
+ * asking why one was refused. A local path is a skill directory or a
4988
+ * directory of skills, discovered exactly as `skill add` would; a bare id
4989
+ * names an installed skill under `~/.stratus/skills/`. Exit 1 when
4990
+ * anything would be refused, so a publish step can gate on it.
4991
+ */
4992
+ export const runSkillValidate = async (command, streams, env = {}) => {
4993
+ let directory;
4994
+ let installed = false;
4995
+ const localPath = path.resolve(readWorkingDirectory(env), command.target);
4996
+ try {
4997
+ if ((await stat(localPath)).isDirectory()) {
4998
+ directory = localPath;
4999
+ }
5000
+ }
5001
+ catch {
5002
+ // Not a local directory; try it as an installed id.
5003
+ }
5004
+ // The loader's rule for the lookup, so a directory that loads with a
5005
+ // pre-spec id can be validated — and told what to rename.
5006
+ if (directory === undefined && isLoadableSkillId(command.target)) {
5007
+ const installedPath = path.join(skillsDirPath(env), command.target);
5008
+ try {
5009
+ if ((await stat(installedPath)).isDirectory()) {
5010
+ directory = installedPath;
5011
+ installed = true;
5012
+ }
5013
+ }
5014
+ catch {
5015
+ // Not installed either.
5016
+ }
5017
+ }
5018
+ if (directory === undefined) {
5019
+ writeLine(streams.stderr, `Error: ${JSON.stringify(command.target)} is neither a directory nor an installed skill id (stratus skills lists those).`);
5020
+ return 1;
5021
+ }
5022
+ // An installed directory IS the layout the spec's directory rule is
5023
+ // about, so its name is checked; a path the author typed is a checkout
5024
+ // or a container, judged as `skill add` would judge it.
5025
+ const { candidates, skipped } = await discoverSkillsInDirectory(directory, installed ? { checkRootDirectoryName: true } : {});
5026
+ if (candidates.length === 0 && skipped.length === 0) {
5027
+ writeLine(streams.stderr, `Error: no skills found in ${directory}. A skill is a directory with a SKILL.md.`);
5028
+ return 1;
5029
+ }
5030
+ for (const candidate of candidates) {
5031
+ const caveat = candidate.warnings.length > 0
5032
+ ? ` — ${candidate.warnings.length} warning${candidate.warnings.length === 1 ? '' : 's'}`
5033
+ : '';
5034
+ writeLine(streams.stdout, `${candidate.id}: ok${caveat}`);
5035
+ for (const warning of candidate.warnings) {
5036
+ writeLine(streams.stdout, ` warning: ${warning}`);
5037
+ }
5038
+ }
5039
+ for (const skip of skipped) {
5040
+ writeLine(streams.stdout, `${skip.id}: refused`);
5041
+ writeLine(streams.stdout, ` error: ${skip.reason}`);
5042
+ }
5043
+ if (skipped.length > 0) {
5044
+ const noun = skipped.length === 1 ? 'skill' : 'skills';
5045
+ writeLine(streams.stderr, `Error: ${skipped.length} ${noun} would be refused at install.`);
5046
+ return 1;
5047
+ }
5048
+ return 0;
5049
+ };
5050
+ /**
5051
+ * The control API base a command should talk to: `--gateway` when given,
5052
+ * else whatever daemon `~/.stratus/gateway.json` says is serving, else
5053
+ * nothing — the file is written when the API binds and removed when it
5054
+ * stops, so its absence means no daemon has said where it is.
5055
+ */
5056
+ const runningGatewayBase = async (env, target) => {
5057
+ if (target.gateway) {
5058
+ return target.gateway.replace(/\/+$/, '');
5059
+ }
5060
+ const info = await readGatewayInfo(env);
5061
+ return info?.url.replace(/\/+$/, '');
5062
+ };
5063
+ /** One authenticated call to a daemon's control API, with the network and auth failures said plainly. */
5064
+ const callRunningGateway = async (env, target, base, pathname, body) => {
5065
+ const token = await gatewayToken(env, target.token);
5066
+ const fetchImpl = env.fetch ?? globalThis.fetch;
5067
+ if (typeof fetchImpl !== 'function') {
5068
+ throw new Error('fetch is unavailable, so this runtime cannot reach a daemon.');
5069
+ }
5070
+ let response;
5071
+ try {
5072
+ response = await fetchImpl(`${base}${pathname}`, {
5073
+ method: 'POST',
5074
+ headers: {
5075
+ authorization: `Bearer ${token}`,
5076
+ ...(body ? { 'content-type': 'application/json' } : {}),
5077
+ },
5078
+ ...(body ? { body: JSON.stringify(body) } : {}),
5079
+ });
5080
+ }
5081
+ catch (error) {
5082
+ throw new Error(`Could not reach the gateway at ${base} (${error instanceof Error ? error.message : String(error)}). `
5083
+ + 'Is stratusd running, and does it have @stratusagent/control-api installed?');
5084
+ }
5085
+ if (response.status === 401 || response.status === 403) {
5086
+ throw new Error(`The gateway at ${base} rejected this token. Check --token, STRATUS_GATEWAY_TOKEN, or ~/.stratus/gateway-token.`);
5087
+ }
5088
+ return response;
5089
+ };
5090
+ /** The API's own sentence for a failure, or the status when it sent none. */
5091
+ const gatewayErrorMessage = async (response) => {
5092
+ try {
5093
+ const payload = await response.json();
5094
+ if (payload.error?.message) {
5095
+ return payload.error.message;
5096
+ }
5097
+ }
5098
+ catch {
5099
+ // Not JSON; the status is all there is to say.
5100
+ }
5101
+ return `HTTP ${response.status}`;
5102
+ };
5103
+ const noRunningDaemonMessage = (env) => `no running daemon found — ${gatewayInfoPath(env)} does not exist, and no --gateway was given. `
5104
+ + 'Start one with `stratus serve` or `stratus service start`; a daemon loads ~/.stratus/skills at start.';
5105
+ /**
5106
+ * After `skill add`: tell the daemon, if one says it is running. Silent
5107
+ * when none does — a `stratus run` user with no daemon should not read a
5108
+ * note about one — and a warning, never a failure, when the file names a
5109
+ * daemon that did not answer: the install itself succeeded.
5110
+ */
5111
+ const reloadRunningDaemonSkills = async (streams, env) => {
5112
+ const info = await readGatewayInfo(env);
5113
+ if (!info) {
5114
+ return;
5115
+ }
5116
+ const base = info.url.replace(/\/+$/, '');
5117
+ try {
5118
+ const response = await callRunningGateway(env, {}, base, '/api/v1/skills/reload');
5119
+ if (!response.ok) {
5120
+ throw new Error(await gatewayErrorMessage(response));
5121
+ }
5122
+ writeLine(streams.stdout, `reloaded the running daemon's skills (${base}) — no restart needed`);
5123
+ }
5124
+ catch (error) {
5125
+ writeLine(streams.stderr, `Warning: ${gatewayInfoPath(env)} names a daemon at ${base}, but its skills were not reloaded: `
5126
+ + `${error instanceof Error ? error.message : String(error)} If it is running, reload it with: stratus skill reload`);
5127
+ }
5128
+ };
5129
+ export const runSkillReload = async (command, streams, env = {}) => {
5130
+ const base = await runningGatewayBase(env, command);
5131
+ if (!base) {
5132
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5133
+ return 1;
5134
+ }
5135
+ const response = await callRunningGateway(env, command, base, '/api/v1/skills/reload');
5136
+ if (!response.ok) {
5137
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5138
+ return 1;
5139
+ }
5140
+ const { skills } = await response.json();
5141
+ const ids = (skills ?? []).map((skill) => skill.id);
5142
+ writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
5143
+ return 0;
5144
+ };
5145
+ export const runRestart = async (command, streams, env = {}) => {
5146
+ const base = await runningGatewayBase(env, command);
5147
+ if (!base) {
5148
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5149
+ return 1;
5150
+ }
5151
+ const response = await callRunningGateway(env, command, base, '/api/v1/restart', {
5152
+ reason: command.reason ?? 'stratus restart',
5153
+ ...(command.drainTimeoutMs !== undefined ? { drainTimeoutMs: command.drainTimeoutMs } : {}),
5154
+ });
5155
+ if (!response.ok) {
5156
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5157
+ return 1;
5158
+ }
5159
+ const status = await response.json();
5160
+ const inflight = status.inflight ?? 0;
5161
+ const window = Math.round((status.drainTimeoutMs ?? 0) / 1000);
5162
+ writeLine(streams.stdout, `restart announced to the daemon at ${base} — new turns are refused; ${inflight} turn(s) in flight `
5163
+ + `get up to ${window}s to finish, then it comes back.`);
5164
+ writeLine(streams.stdout, 'Watch it come back: stratus logs -f');
5165
+ return 0;
5166
+ };
4567
5167
  export const runSkills = async (streams, env = {}) => {
4568
5168
  const registry = new SkillRegistry();
4569
5169
  const skills = await loadOperatorSkills(env, registry, (line) => {
@@ -4808,6 +5408,85 @@ const gatewayAnswering = async (env, base, fetchImpl) => {
4808
5408
  return false;
4809
5409
  }
4810
5410
  };
5411
+ /**
5412
+ * Whether a process with this pid exists. EPERM is an answer — the process
5413
+ * is there, it is just not ours to signal.
5414
+ */
5415
+ const processAlive = (pid) => {
5416
+ try {
5417
+ process.kill(pid, 0);
5418
+ return true;
5419
+ }
5420
+ catch (error) {
5421
+ return error.code === 'EPERM';
5422
+ }
5423
+ };
5424
+ /**
5425
+ * The error for a home another daemon holds (see `claimHome` in
5426
+ * @stratusagent/gateway — the claim is what decides; this only says who).
5427
+ *
5428
+ * The discovery file names the holder when it has one to name: a daemon
5429
+ * that has bound its API. Read for the message alone, and only trusted as
5430
+ * far as a live pid — a daemon still starting has not written it, a
5431
+ * daemon draining its last turn has already removed it, and a SIGKILLed
5432
+ * one leaves it behind for the next pid to inherit. Not probed over HTTP:
5433
+ * a `STRATUS_GATEWAY_TOKEN` exported for some remote gateway would answer
5434
+ * 401 for the local one, and the claim needs no second opinion.
5435
+ */
5436
+ /**
5437
+ * `stratus serve` refused because another daemon holds the home. Its own
5438
+ * type so `stratus dashboard` can tell "a daemon is there, wait for it to
5439
+ * publish" from "the daemon could not start".
5440
+ */
5441
+ class HomeHeldError extends Error {
5442
+ constructor(message) {
5443
+ super(message);
5444
+ this.name = 'HomeHeldError';
5445
+ }
5446
+ }
5447
+ /**
5448
+ * Whether a daemon from before the lock existed is serving this home.
5449
+ *
5450
+ * Such a daemon — alive across an upgrade that did not stop it — never
5451
+ * took the claim, so the claim alone cannot see it. The discovery file it
5452
+ * published is its only trace, held to the two proofs the lock made
5453
+ * unnecessary for everything since: the pid it names is alive, and its
5454
+ * URL answers `/health` with this home's own token file — never the
5455
+ * `STRATUS_GATEWAY_TOKEN` override, which names some other gateway and
5456
+ * would make a live daemon read as absent. A stale file fails either
5457
+ * proof and refuses nothing.
5458
+ */
5459
+ const legacyDaemonServing = async (env) => {
5460
+ const info = await readGatewayInfo(env);
5461
+ if (info?.pid === undefined || info.pid === process.pid || !processAlive(info.pid)) {
5462
+ return false;
5463
+ }
5464
+ const fetchImpl = env.fetch ?? globalThis.fetch;
5465
+ if (typeof fetchImpl !== 'function') {
5466
+ return false;
5467
+ }
5468
+ try {
5469
+ const token = (await readFile(gatewayTokenPath(env), 'utf8')).trim();
5470
+ const response = await fetchImpl(`${info.url}/api/v1/health`, {
5471
+ headers: { authorization: `Bearer ${token}` },
5472
+ signal: AbortSignal.timeout(2_000),
5473
+ });
5474
+ return response.ok;
5475
+ }
5476
+ catch {
5477
+ return false;
5478
+ }
5479
+ };
5480
+ const describeHeldHome = async (env) => {
5481
+ const info = await readGatewayInfo(env);
5482
+ const holder = info?.pid !== undefined && processAlive(info.pid)
5483
+ ? ` (pid ${info.pid}, ${info.url})`
5484
+ : ' — one that is still starting, or still draining its last turns';
5485
+ return `stratusd is already running for this home${holder}. Two daemons on one ~/.stratus `
5486
+ + 'would each fire the other\'s schedules and re-ask its approvals. Stop it first: `stratus service stop` '
5487
+ + 'if it is the installed service, otherwise Ctrl+C where it runs — and let it finish; it holds the home until '
5488
+ + 'its last turn is written.';
5489
+ };
4811
5490
  /**
4812
5491
  * `stratus dashboard` — open the web UI against a running daemon, starting
4813
5492
  * one in the foreground when there is none.
@@ -4830,56 +5509,87 @@ export const runDashboard = async (command, streams, env = {}) => {
4830
5509
  if (!base) {
4831
5510
  writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
4832
5511
  writeLine(streams.stdout, 'Run `stratus service install` to keep one running instead.');
4833
- serving = runServe({
4834
- command: 'serve',
4835
- events: false,
4836
- // Explicitly on. A trusted config may set `api.enabled: false` — a
4837
- // reasonable thing for a headless box — but this command exists to
4838
- // open the dashboard, and honouring it here would start a daemon
4839
- // with no API and then time out waiting for the one it promised.
4840
- api: true,
4841
- ...(command.port !== undefined ? { apiPort: command.port } : {}),
4842
- // Passed whenever it was asked for, including when it is the default.
4843
- // `--host 127.0.0.1` against a config saying `0.0.0.0` is an operator
4844
- // narrowing the bind, and dropping it because it matched the default
4845
- // did the reverse of what they typed.
4846
- ...(command.host !== undefined ? { apiHost: command.host } : {}),
4847
- },
4848
- // The daemon's own chatter belongs on stderr here: stdout is where this
4849
- // command says where to point a browser, and interleaving the two makes
4850
- // the one line that matters hard to find.
4851
- { stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
4852
- // Attached now, not at the end. `runServe` can reject before it publishes
4853
- // anything — an unreadable roster, a session store that will not open —
4854
- // and a rejected promise nobody is watching for fifteen seconds is an
4855
- // unhandled rejection, which terminates the process instead of reaching
4856
- // the message below. Captured rather than swallowed, so the reason the
4857
- // daemon gave is the reason this command reports.
5512
+ // One attempt at a daemon of our own. `runServe` can reject before it
5513
+ // publishes anything — an unreadable roster, a session store that will
5514
+ // not open — and a rejected promise nobody is watching for fifteen
5515
+ // seconds is an unhandled rejection, which terminates the process
5516
+ // instead of reaching the message below. Captured rather than
5517
+ // swallowed, so the reason the daemon gave is the reason this command
5518
+ // reports.
4858
5519
  let daemonFailure;
4859
- const daemonExit = serving.then(() => undefined, (error) => { daemonFailure = error; });
4860
- // Gated on the daemon actually answering, not on a delay: it publishes
5520
+ let daemonSettled = false;
5521
+ let daemonExit = Promise.resolve();
5522
+ let attemptedAt = 0;
5523
+ const attempt = () => {
5524
+ daemonFailure = undefined;
5525
+ daemonSettled = false;
5526
+ attemptedAt = Date.now();
5527
+ serving = runServe({
5528
+ command: 'serve',
5529
+ events: false,
5530
+ // Explicitly on. A trusted config may set `api.enabled: false` —
5531
+ // a reasonable thing for a headless box — but this command exists
5532
+ // to open the dashboard, and honouring it here would start a
5533
+ // daemon with no API and then time out waiting for the one it
5534
+ // promised.
5535
+ api: true,
5536
+ ...(command.port !== undefined ? { apiPort: command.port } : {}),
5537
+ // Passed whenever it was asked for, including when it is the
5538
+ // default. `--host 127.0.0.1` against a config saying `0.0.0.0`
5539
+ // is an operator narrowing the bind, and dropping it because it
5540
+ // matched the default did the reverse of what they typed.
5541
+ ...(command.host !== undefined ? { apiHost: command.host } : {}),
5542
+ },
5543
+ // The daemon's own chatter belongs on stderr here: stdout is where
5544
+ // this command says where to point a browser, and interleaving the
5545
+ // two makes the one line that matters hard to find.
5546
+ { stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
5547
+ daemonExit = serving.then(() => { daemonSettled = true; }, (error) => { daemonFailure = error; daemonSettled = true; });
5548
+ };
5549
+ attempt();
5550
+ // Gated on a daemon actually answering, not on a delay: one publishes
4861
5551
  // where it bound the moment it binds, and anything less would be a race
4862
- // dressed up as a timeout. It also stops the moment the daemon gives up,
4863
- // rather than waiting out a timeout for a process that is already gone.
5552
+ // dressed up as a timeout. It also stops the moment our daemon gives up
5553
+ // — except when it gave up because another one holds the home. An
5554
+ // installed service still starting has not published its address yet
5555
+ // and is about to: that is the daemon to wait for. And a daemon still
5556
+ // draining may simply exit, with nothing replacing it — so while the
5557
+ // home is held, our own attempt is repeated about once a second, and
5558
+ // takes the home the moment it is free.
4864
5559
  const startedBy = Date.now() + 15_000;
4865
- let daemonStopped = false;
4866
- void daemonExit.then(() => { daemonStopped = true; });
4867
- while (!base && !daemonStopped && Date.now() < startedBy) {
5560
+ while (!base && Date.now() < startedBy) {
4868
5561
  const info = await readGatewayInfo(env);
4869
5562
  if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
4870
5563
  base = info.url;
4871
5564
  break;
4872
5565
  }
5566
+ if (daemonSettled) {
5567
+ if (!(daemonFailure instanceof HomeHeldError)) {
5568
+ break;
5569
+ }
5570
+ if (Date.now() - attemptedAt >= 1_000) {
5571
+ attempt();
5572
+ }
5573
+ }
4873
5574
  await new Promise((resolve) => setTimeout(resolve, 150));
4874
5575
  }
4875
5576
  if (!base) {
4876
5577
  ownDaemon.abort();
4877
5578
  await daemonExit;
4878
- writeLine(streams.stderr, daemonFailure
4879
- ? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
4880
- : 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
5579
+ writeLine(streams.stderr, daemonFailure instanceof HomeHeldError
5580
+ ? 'Error: another daemon holds this home but never published its address. It may still be starting or '
5581
+ + 'draining — try again in a moment, or check `stratus service status`.'
5582
+ : daemonFailure
5583
+ ? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
5584
+ : 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
4881
5585
  return 1;
4882
5586
  }
5587
+ if (daemonFailure instanceof HomeHeldError) {
5588
+ // The daemon that answered is the one that held the home. Ours never
5589
+ // started, so there is nothing of ours to wait for or to stop: from
5590
+ // here this is the found-a-running-daemon case.
5591
+ serving = undefined;
5592
+ }
4883
5593
  }
4884
5594
  let url;
4885
5595
  try {
@@ -4915,7 +5625,15 @@ export const runDashboard = async (command, streams, env = {}) => {
4915
5625
  await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
4916
5626
  ownDaemon.abort();
4917
5627
  }
4918
- return serving;
5628
+ // An attempt still in flight when the daemon answered may yet be refused
5629
+ // — the holder published while ours was claiming. The dashboard found a
5630
+ // daemon; that refusal is not its failure.
5631
+ return serving.catch((error) => {
5632
+ if (error instanceof HomeHeldError) {
5633
+ return 0;
5634
+ }
5635
+ throw error;
5636
+ });
4919
5637
  };
4920
5638
  /**
4921
5639
  * Resolution and loading are separate questions, and only the first one
@@ -5018,6 +5736,214 @@ const loadServePlugins = async (env, configPath, warn) => {
5018
5736
  * roster, accept dispatches, print events, and drain cleanly on SIGTERM,
5019
5737
  * SIGINT, or the injected shutdown signal.
5020
5738
  */
5739
+ /**
5740
+ * What a daemon exits with to ask for a fresh process. Only ever seen by the
5741
+ * supervisor below, which is the only thing that starts a daemon with the
5742
+ * environment marker that makes it exit this way instead of supervising.
5743
+ */
5744
+ export const RESTART_EXIT_CODE = 75;
5745
+ /**
5746
+ * What a daemon exits with when a restart could not drain — a turn that
5747
+ * ignored its abort, a plugin or channel that did not let go. Distinct from
5748
+ * the restart status on purpose: the supervisor answers this one by
5749
+ * exiting with it too, so the process the service manager started ends
5750
+ * and the manager restarts the whole unit — under systemd, cleaning the
5751
+ * cgroup of whatever the plugin left running. A supervisor that started
5752
+ * another daemon instead would keep that cgroup, and its leaks, alive.
5753
+ */
5754
+ export const UNDRAINED_RESTART_EXIT_CODE = 76;
5755
+ /** Set in a daemon the supervisor started, so its own restart is an exit, not a second supervisor. */
5756
+ const SUPERVISED_ENV = 'STRATUS_SERVE_SUPERVISED';
5757
+ /**
5758
+ * The control API port the last daemon bound, handed to its replacement by
5759
+ * the supervisor. Honoured only where the replacement's own request — the
5760
+ * flag, else the config it reads afresh — is still for any free port: a
5761
+ * daemon that asked for port 0 comes back on the port it had, so a
5762
+ * dashboard page reconnects to it, while a config since edited to a fixed
5763
+ * port takes effect on that restart, as an `api` block change is meant to.
5764
+ * A hint, never a pin: passing the port back as `--api-port` would outrank
5765
+ * the config for every restart after.
5766
+ */
5767
+ const BOUND_API_PORT_ENV = 'STRATUS_SERVE_BOUND_API_PORT';
5768
+ const isDashboardSession = (value) => typeof value === 'object' && value !== null
5769
+ && typeof value.id === 'string'
5770
+ && typeof value.expiresAt === 'number'
5771
+ && typeof value.vouchedBy === 'string';
5772
+ /** Shape-checked, because the other end of an IPC channel is still another process. */
5773
+ const isSupervisorMessage = (value) => {
5774
+ if (typeof value !== 'object' || value === null) {
5775
+ return false;
5776
+ }
5777
+ const message = value;
5778
+ if (message.type === 'stratusd.bound-api-port') {
5779
+ return typeof message.port === 'number' && Number.isInteger(message.port) && message.port > 0;
5780
+ }
5781
+ if (message.type === 'stratusd.sessions') {
5782
+ return Array.isArray(message.sessions) && message.sessions.every(isDashboardSession);
5783
+ }
5784
+ return false;
5785
+ };
5786
+ /**
5787
+ * The IPC channel a supervisor opens when it spawns a daemon: what a
5788
+ * supervised daemon says up it (the port it bound, so the supervisor
5789
+ * carries *that* port to the next daemon rather than the first one's; the
5790
+ * dashboard sessions it hands on) and what comes down it. Where there is
5791
+ * no channel — the first daemon, or one a service manager started — there
5792
+ * is no one to say it to, and nothing is said.
5793
+ */
5794
+ const defaultSupervisorLink = {
5795
+ send(message) {
5796
+ if (typeof process.send === 'function' && process.channel) {
5797
+ process.send(message);
5798
+ }
5799
+ },
5800
+ receive(handler) {
5801
+ const channel = process.channel;
5802
+ if (!channel) {
5803
+ return;
5804
+ }
5805
+ process.on('message', (message) => {
5806
+ if (isSupervisorMessage(message)) {
5807
+ handler(message);
5808
+ }
5809
+ });
5810
+ // A 'message' listener refs the channel, which would keep this process
5811
+ // alive after its drain; the daemon's own work is what holds it open.
5812
+ channel.unref?.();
5813
+ },
5814
+ };
5815
+ /**
5816
+ * The parsed serve command as arguments again — what the fresh daemon is
5817
+ * started with. From the command, not from `process.argv`: `stratus
5818
+ * dashboard` runs the daemon through `runServe` with a command it built,
5819
+ * and the process's own arguments would start a second dashboard.
5820
+ */
5821
+ export const serveArgv = (command) => [
5822
+ 'serve',
5823
+ ...(command.configPath !== undefined ? ['--config', command.configPath] : []),
5824
+ ...(command.idleTimeoutMs !== undefined ? ['--idle-timeout', String(command.idleTimeoutMs / 1000)] : []),
5825
+ ...(command.approvals !== undefined ? ['--approvals', command.approvals] : []),
5826
+ ...(command.events ? [] : ['--no-events']),
5827
+ ...(command.logToFile === false ? ['--no-log-file'] : []),
5828
+ ...(command.api === false ? ['--no-api'] : command.api === true ? ['--api'] : []),
5829
+ ...(command.apiPort !== undefined ? ['--api-port', String(command.apiPort)] : []),
5830
+ ...(command.apiHost !== undefined ? ['--api-host', command.apiHost] : []),
5831
+ ];
5832
+ /**
5833
+ * The `bin` beside the module at `moduleUrl`, with that module's own
5834
+ * extension: `dist/index.js` respawns `dist/bin.js`, and a source checkout
5835
+ * running `src/index.ts` under type stripping respawns `src/bin.ts` — the
5836
+ * flags that made that possible travel in `process.execArgv`. Assuming
5837
+ * compiled output would hand a source checkout a file that does not exist,
5838
+ * and a daemon that never comes back.
5839
+ */
5840
+ export const restartEntrypoint = (moduleUrl) => {
5841
+ const modulePath = fileURLToPath(moduleUrl);
5842
+ return path.join(path.dirname(modulePath), `bin${path.extname(modulePath)}`);
5843
+ };
5844
+ /**
5845
+ * Run this CLI's own entrypoint as a child with the daemon's streams and
5846
+ * exit code, forwarding the signals a supervisor would otherwise swallow.
5847
+ *
5848
+ * A child, and the parent waits, rather than a detached process and an
5849
+ * exit: under systemd and launchd the process the manager started is the
5850
+ * service, and its exit ends the job — cgroup and all, on Linux — so the
5851
+ * daemon that received the restart has to stay the manager's process and
5852
+ * become the supervisor of the next one. That is what makes the same path
5853
+ * hold in the foreground and under `--no-login`, where nothing else would
5854
+ * bring a clean exit back.
5855
+ */
5856
+ const defaultServeRespawn = (env) => (argv, handoff) => new Promise((resolve, reject) => {
5857
+ const entrypoint = restartEntrypoint(import.meta.url);
5858
+ // The daemon's own streams, plus an IPC channel for what it and its
5859
+ // supervisor have to tell each other. See SupervisorLink.
5860
+ const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...argv], {
5861
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
5862
+ env: {
5863
+ ...process.env,
5864
+ [SUPERVISED_ENV]: '1',
5865
+ ...(handoff.boundApiPort !== undefined ? { [BOUND_API_PORT_ENV]: String(handoff.boundApiPort) } : {}),
5866
+ },
5867
+ });
5868
+ if (handoff.sessions.length > 0 && child.connected) {
5869
+ // Queued in the channel until the daemon listens, which it does
5870
+ // before its API is up; the API holds them until it can adopt them.
5871
+ // A send that fails (the daemon died before reading) is not this
5872
+ // supervisor's failure: the exit below reports what happened, and a
5873
+ // daemon that never took them simply comes up signed out.
5874
+ const sessions = { type: 'stratusd.sessions', sessions: handoff.sessions };
5875
+ child.send(sessions, () => undefined);
5876
+ }
5877
+ const forward = (signal) => () => {
5878
+ child.kill(signal);
5879
+ };
5880
+ const onTerm = forward('SIGTERM');
5881
+ const onInt = forward('SIGINT');
5882
+ const onAbort = () => {
5883
+ child.kill('SIGTERM');
5884
+ };
5885
+ process.on('SIGTERM', onTerm);
5886
+ process.on('SIGINT', onInt);
5887
+ env.shutdownSignal?.addEventListener('abort', onAbort, { once: true });
5888
+ const done = () => {
5889
+ process.off('SIGTERM', onTerm);
5890
+ process.off('SIGINT', onInt);
5891
+ env.shutdownSignal?.removeEventListener('abort', onAbort);
5892
+ };
5893
+ let reportedPort;
5894
+ let handedBack;
5895
+ child.on('message', (message) => {
5896
+ if (!isSupervisorMessage(message)) {
5897
+ return;
5898
+ }
5899
+ if (message.type === 'stratusd.bound-api-port') {
5900
+ reportedPort = message.port;
5901
+ }
5902
+ else {
5903
+ handedBack = message.sessions;
5904
+ }
5905
+ });
5906
+ child.once('error', (error) => {
5907
+ done();
5908
+ reject(error);
5909
+ });
5910
+ child.once('exit', (code, signal) => {
5911
+ done();
5912
+ // Killed by a signal is a stop, not a request to come back.
5913
+ resolve({
5914
+ code: code ?? (signal ? 1 : 0),
5915
+ ...(reportedPort !== undefined ? { boundApiPort: reportedPort } : {}),
5916
+ ...(handedBack !== undefined ? { sessions: handedBack } : {}),
5917
+ });
5918
+ });
5919
+ });
5920
+ /**
5921
+ * Keep starting daemons for as long as they exit asking for another; the
5922
+ * first exit that does not is this process's own — an undrained restart's
5923
+ * status included, which ends the supervisor rather than starting a daemon
5924
+ * beside what the last one could not release.
5925
+ */
5926
+ const superviseRestarts = async (command, first, streams, env) => {
5927
+ const respawn = env.serveRespawn ?? defaultServeRespawn(env);
5928
+ // The hand-off follows the daemons: the port each one said it bound is
5929
+ // what the next one is told (so a config edited to a fixed port and
5930
+ // back hands on the last address, not the first), and the sessions are
5931
+ // whatever the last one handed back — a daemon that handed nothing
5932
+ // hands nothing on.
5933
+ let handoff = first;
5934
+ let result;
5935
+ do {
5936
+ writeLine(streams.stdout, 'Restarting stratusd.');
5937
+ result = await respawn(serveArgv(command), handoff);
5938
+ handoff = {
5939
+ ...(result.boundApiPort !== undefined || handoff.boundApiPort !== undefined
5940
+ ? { boundApiPort: result.boundApiPort ?? handoff.boundApiPort }
5941
+ : {}),
5942
+ sessions: result.sessions ?? [],
5943
+ };
5944
+ } while (result.code === RESTART_EXIT_CODE);
5945
+ return result.code;
5946
+ };
5021
5947
  export const runServe = async (command, streams, env = {}) => {
5022
5948
  // First, before anything that can throw. A broken install makes the
5023
5949
  // gateway import below fail, the manager restarts, and the CLI's error
@@ -5030,7 +5956,46 @@ export const runServe = async (command, streams, env = {}) => {
5030
5956
  // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
5031
5957
  // command neither needs nor should pay for (Node still prints an
5032
5958
  // experimental warning for it).
5033
- const { createGateway } = await import('@stratusagent/gateway');
5959
+ const { createGateway, claimHome, HomeClaimedError } = await import('@stratusagent/gateway');
5960
+ // Before anything of this daemon's is written or opened: a second daemon
5961
+ // on this home is refused here, with no log line, no store, and no sweep
5962
+ // or firing of its own.
5963
+ let claim;
5964
+ try {
5965
+ claim = claimHome(env);
5966
+ }
5967
+ catch (error) {
5968
+ if (error instanceof HomeClaimedError) {
5969
+ throw new HomeHeldError(await describeHeldHome(env));
5970
+ }
5971
+ throw error;
5972
+ }
5973
+ let outcome;
5974
+ try {
5975
+ // The one daemon the claim cannot see: one that predates it.
5976
+ if (await legacyDaemonServing(env)) {
5977
+ throw new HomeHeldError(await describeHeldHome(env));
5978
+ }
5979
+ outcome = await serveHeldHome(command, streams, env, createGateway);
5980
+ }
5981
+ finally {
5982
+ // After everything: the store closed by stop(), or whatever a throw
5983
+ // reached — a preflight that rejected before the gateway existed
5984
+ // included, so a host that calls runServe again is not refused for a
5985
+ // daemon that never started. And never sooner, so a replacement cannot
5986
+ // open the store while this one still writes.
5987
+ claim.release();
5988
+ }
5989
+ // An announced restart, once the home is let go — the replacement claims
5990
+ // it next. A daemon the supervisor started asks it for the next one by
5991
+ // exiting; the first daemon becomes the supervisor itself. Either way the
5992
+ // process the service manager (or the terminal) started is what stays.
5993
+ if (outcome.code === RESTART_EXIT_CODE && !readProcessEnv(env)[SUPERVISED_ENV]) {
5994
+ return superviseRestarts(command, { ...(outcome.boundApiPort !== undefined ? { boundApiPort: outcome.boundApiPort } : {}), sessions: outcome.sessions }, streams, env);
5995
+ }
5996
+ return outcome.code;
5997
+ };
5998
+ const serveHeldHome = async (command, streams, env, createGateway) => {
5034
5999
  // Under a service manager the daemon's stdout is gone, so everything it
5035
6000
  // says is also written to ~/.stratus/logs — that file is what `stratus
5036
6001
  // logs` reads, and the only record of an overnight run.
@@ -5069,6 +6034,8 @@ export const runServe = async (command, streams, env = {}) => {
5069
6034
  // Typed as the seam, not as whatever the first push happens to be: the
5070
6035
  // list holds the control API and the Slack adapter alike.
5071
6036
  const controlApiChannels = [];
6037
+ /** The API adapter itself, for what the seam does not carry: its address, and the sessions it hands across a restart. */
6038
+ let boundApi;
5072
6039
  if (apiWanted) {
5073
6040
  const createControlApi = await loadControlApi();
5074
6041
  // Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
@@ -5077,16 +6044,23 @@ export const runServe = async (command, streams, env = {}) => {
5077
6044
  // Everything still bound and every test still passed, on whichever port
5078
6045
  // nobody happened to be using.
5079
6046
  const apiHost = command.apiHost ?? apiConfig.host;
5080
- const apiPort = command.apiPort ?? apiConfig.port;
6047
+ const requestedPort = command.apiPort ?? apiConfig.port;
6048
+ // The port the last daemon bound, from the supervisor that started
6049
+ // this one — taken only where this daemon's own request is still for
6050
+ // any free port. See BOUND_API_PORT_ENV.
6051
+ const hinted = Number(readProcessEnv(env)[BOUND_API_PORT_ENV]);
6052
+ const apiPort = requestedPort === 0 && Number.isInteger(hinted) && hinted > 0 ? hinted : requestedPort;
5081
6053
  if (createControlApi) {
5082
- controlApiChannels.push(createControlApi({
6054
+ const api = createControlApi({
5083
6055
  env,
5084
6056
  ...(apiHost !== undefined ? { host: apiHost } : {}),
5085
6057
  ...(apiPort !== undefined ? { port: apiPort } : {}),
5086
6058
  ...(command.configPath ? { configPath: command.configPath } : {}),
5087
6059
  log,
5088
6060
  warn,
5089
- }));
6061
+ });
6062
+ boundApi = api;
6063
+ controlApiChannels.push(api);
5090
6064
  }
5091
6065
  else if (command.api === true || apiConfig.enabled === true) {
5092
6066
  // Only when someone asked for it explicitly. A daemon that was never
@@ -5095,6 +6069,18 @@ export const runServe = async (command, streams, env = {}) => {
5095
6069
  + 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
5096
6070
  }
5097
6071
  }
6072
+ // Listening before anything else is awaited: what the supervisor hands
6073
+ // this daemon is queued in the channel until then, and the API keeps it
6074
+ // until it is serving.
6075
+ const supervised = Boolean(readProcessEnv(env)[SUPERVISED_ENV]);
6076
+ const link = env.supervisorLink ?? defaultSupervisorLink;
6077
+ if (supervised) {
6078
+ link.receive((message) => {
6079
+ if (message.type === 'stratusd.sessions') {
6080
+ boundApi?.adoptSessions(message.sessions);
6081
+ }
6082
+ });
6083
+ }
5098
6084
  const channelCredentials = await loadChannelCredentials(env);
5099
6085
  const slackAgents = Object.entries(channelCredentials.slack ?? {});
5100
6086
  const channels = [...controlApiChannels];
@@ -5192,7 +6178,10 @@ export const runServe = async (command, streams, env = {}) => {
5192
6178
  // a tool that carries no command string is judged by its risk exactly as
5193
6179
  // before. The whitelist lives beside the agent's soul, per agent.
5194
6180
  const commands = {
5195
- whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env) }),
6181
+ // A whitelist that exists and will not read is said here, once, and
6182
+ // never written over — the daemon's log is where a grant list going
6183
+ // quiet would otherwise go unnoticed.
6184
+ whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env), warn }),
5196
6185
  onScopeRemembered: ({ agentId, scope }) => {
5197
6186
  // An approval that widens what runs unattended, for every future
5198
6187
  // session, is precisely the decision that must not be the one leaving
@@ -5200,14 +6189,24 @@ export const runServe = async (command, streams, env = {}) => {
5200
6189
  log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
5201
6190
  },
5202
6191
  };
6192
+ // The origin-scope engine, whose grants live in the same file as the
6193
+ // command scopes: one place an operator looks to see what an agent may
6194
+ // do unattended. Wired unconditionally for the same reason — a tool that
6195
+ // names no origin is judged by its risk exactly as before.
6196
+ const origins = {
6197
+ whitelist: commands.whitelist,
6198
+ onScopeRemembered: ({ agentId, scope }) => {
6199
+ log(`${agentId}: ${describeOriginScope(scope)} is now acted on without asking`);
6200
+ },
6201
+ };
5203
6202
  const approvals = (transport) => createPermissionPolicy(
5204
6203
  // The destination scope rides along in BOTH modes — it is what lets a
5205
6204
  // scheduled turn report to the channel a human approved with the
5206
6205
  // schedule, and headless (where every other gated call is refused) is
5207
6206
  // exactly the deployment it exists for.
5208
6207
  approvalMode === 'remote'
5209
- ? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
5210
- : { mode: 'headless', onDecision, commands, destinations: transport.destinations });
6208
+ ? { mode: 'remote', request: transport.request, onDecision, commands, origins, destinations: transport.destinations }
6209
+ : { mode: 'headless', onDecision, commands, origins, destinations: transport.destinations });
5211
6210
  if (approvalMode === 'remote') {
5212
6211
  // Only agents whose channel actually came up can be asked: tokens on
5213
6212
  // disk with the Slack package missing means nothing renders the
@@ -5215,9 +6214,30 @@ export const runServe = async (command, streams, env = {}) => {
5215
6214
  const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
5216
6215
  log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
5217
6216
  }
6217
+ /**
6218
+ * The one signal handler this daemon has, for the whole of its life —
6219
+ * installed by the wait below, removed in the finally after the drain.
6220
+ * See the wait for why it is never `once` and never removed sooner.
6221
+ */
6222
+ let stopSignal;
6223
+ let repeatWarned = false;
6224
+ // The gateway's restart hands the process back here once it has stopped:
6225
+ // the outcome is kept and the wait below released, and the tail of this
6226
+ // function decides what "come back" means for this process.
6227
+ let restart;
6228
+ /** A signal or the host's abort asked for a stop — set when the wait ends for that reason. */
6229
+ let stopRequested = false;
6230
+ let requestShutdown = () => { };
6231
+ const shutdownRequested = new Promise((resolve) => {
6232
+ requestShutdown = resolve;
6233
+ });
5218
6234
  const gateway = createGateway({
5219
6235
  env,
5220
6236
  approvals,
6237
+ onRestart: (outcome) => {
6238
+ restart = outcome;
6239
+ requestShutdown();
6240
+ },
5221
6241
  ...(Object.keys(pluginsConfig).length > 0
5222
6242
  ? {
5223
6243
  plugins: pluginsConfig,
@@ -5299,36 +6319,46 @@ export const runServe = async (command, streams, env = {}) => {
5299
6319
  });
5300
6320
  }
5301
6321
  await gateway.start();
5302
- // The roster is only known once the gateway has loaded it, and in remote
5303
- // mode an agent no channel can ask for is the quietest failure this
5304
- // feature has: its gated calls park with nobody rendering them and wait
5305
- // out the whole timeout before being denied. No channel can detect this
5306
- // on its own — a request is a broadcast, and no adapter knows whether
5307
- // another one is about to answer — so it is reported here, where the
5308
- // roster and the channel list are both in view.
5309
- if (approvalMode === 'remote') {
5310
- const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
5311
- const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
5312
- if (unreachable.length > 0) {
5313
- warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
5314
- + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
5315
- }
5316
- }
5317
- writeLine(streams.stdout, 'Press Ctrl+C to stop.');
5318
- // And periodically, for a long-running daemon that warns steadily
5319
- // without ever writing enough records to rotate. Unref'd, so it never
5320
- // holds the process open. Armed only once the daemon is actually
5321
- // serving: every step above can throw, and runServe is an exported
5322
- // function as much as a process entry point — in a host that survives
5323
- // the failure, a timer armed before the throw would outlive the call
5324
- // and go on truncating that environment's redirect logs. The finally
5325
- // below takes it down on every other exit path.
6322
+ // Read now, while the API is bound: its stop forgets the address, and
6323
+ // a restart's respawn needs it (see ServeOutcome).
6324
+ const boundApiPort = boundApi?.url !== undefined ? Number(new URL(boundApi.url).port) : undefined;
6325
+ if (supervised && boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0) {
6326
+ link.send({ type: 'stratusd.bound-api-port', port: boundApiPort });
6327
+ }
6328
+ // Under one finally from here: a throw anywhere after the start — a
6329
+ // host's writable refusing a line, say — must still stop the gateway
6330
+ // before the claim on the home is released, or a live gateway would be
6331
+ // left behind a lock the next daemon can take.
5326
6332
  let redirectTimer;
5327
- if (logWriter) {
5328
- redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
5329
- redirectTimer.unref?.();
5330
- }
5331
6333
  try {
6334
+ // The roster is only known once the gateway has loaded it, and in remote
6335
+ // mode an agent no channel can ask for is the quietest failure this
6336
+ // feature has: its gated calls park with nobody rendering them and wait
6337
+ // out the whole timeout before being denied. No channel can detect this
6338
+ // on its own — a request is a broadcast, and no adapter knows whether
6339
+ // another one is about to answer — so it is reported here, where the
6340
+ // roster and the channel list are both in view.
6341
+ if (approvalMode === 'remote') {
6342
+ const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
6343
+ const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
6344
+ if (unreachable.length > 0) {
6345
+ warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
6346
+ + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
6347
+ }
6348
+ }
6349
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
6350
+ // And periodically, for a long-running daemon that warns steadily
6351
+ // without ever writing enough records to rotate. Unref'd, so it never
6352
+ // holds the process open. Armed only once the daemon is actually
6353
+ // serving: every step above can throw, and runServe is an exported
6354
+ // function as much as a process entry point — in a host that survives
6355
+ // the failure, a timer armed before the throw would outlive the call
6356
+ // and go on truncating that environment's redirect logs. The finally
6357
+ // below takes it down on every other exit path.
6358
+ if (logWriter) {
6359
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
6360
+ redirectTimer.unref?.();
6361
+ }
5332
6362
  // Hold the event loop open until a shutdown request, then drain: new
5333
6363
  // dispatches are refused while in-flight turns finish.
5334
6364
  await new Promise((resolve) => {
@@ -5340,12 +6370,46 @@ export const runServe = async (command, streams, env = {}) => {
5340
6370
  }
5341
6371
  settled = true;
5342
6372
  clearInterval(keepAlive);
5343
- process.off('SIGTERM', shutdown);
5344
- process.off('SIGINT', shutdown);
5345
6373
  resolve();
5346
6374
  };
5347
- process.once('SIGTERM', shutdown);
5348
- process.once('SIGINT', shutdown);
6375
+ stopSignal = () => {
6376
+ if (!settled) {
6377
+ shutdown();
6378
+ return;
6379
+ }
6380
+ // A signal while a drain is already running — a repeat, or a stop
6381
+ // arriving during a restart's drain. Either way the drain goes on,
6382
+ // and a daemon told to stop does not come back.
6383
+ stopRequested = true;
6384
+ if (!repeatWarned) {
6385
+ repeatWarned = true;
6386
+ // Said from signal dispatch, outside the promise and its finally:
6387
+ // a stream that throws on write (a host's injected stderr) would
6388
+ // otherwise end the process from here — mid-drain, the very
6389
+ // thing this handler exists to prevent. The word is best effort.
6390
+ try {
6391
+ warn('stop signal received while draining; still draining, and this daemon will not come back (SIGKILL ends it at once)');
6392
+ }
6393
+ catch {
6394
+ // Nothing to say it to; the drain is what matters.
6395
+ }
6396
+ }
6397
+ };
6398
+ // `on`, never `once`, and not removed until the drain is over (the
6399
+ // finally below). `once` drops the listener before running it, Node
6400
+ // uninstalls its native handler the moment no listener is left, and
6401
+ // a second SIGTERM landing in that window ends the process by the
6402
+ // default action — mid-drain, with nothing said. The second signal
6403
+ // is the normal case, not an edge: a stop delivered to the process
6404
+ // group, as systemd's KillMode=control-group and a terminal's Ctrl+C
6405
+ // deliver it, reaches a supervised daemon once from the kernel and
6406
+ // once more forwarded by its supervisor. Reproduced two runs in
6407
+ // three: the replacement died and the supervisor exited 1 on a
6408
+ // `stratus service stop` after a restart. SIGKILL still ends the
6409
+ // process at once.
6410
+ process.on('SIGTERM', stopSignal);
6411
+ process.on('SIGINT', stopSignal);
6412
+ void shutdownRequested.then(shutdown);
5349
6413
  if (env.shutdownSignal?.aborted) {
5350
6414
  shutdown();
5351
6415
  }
@@ -5353,20 +6417,85 @@ export const runServe = async (command, streams, env = {}) => {
5353
6417
  env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
5354
6418
  }
5355
6419
  });
5356
- writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
5357
- await gateway.stop();
5358
- return 0;
6420
+ // Decided now, before the stop below: a restart's drain can still be
6421
+ // running when a stop signal releases this wait, and `onRestart` then
6422
+ // fires during that stop. A daemon told to stop does not come back,
6423
+ // whatever it was told before.
6424
+ if (!restart) {
6425
+ stopRequested = true;
6426
+ }
6427
+ if (restart) {
6428
+ // Already stopped: the gateway drained and closed before handing the
6429
+ // process over, and the stop() below finds nothing to do. Said here,
6430
+ // while the structured log still takes writes — the next lines
6431
+ // belong to a process that has none.
6432
+ if (!restart.drained) {
6433
+ // A turn that ignored its abort is still running in this process,
6434
+ // possibly still writing sessions. Starting a fresh daemon beside
6435
+ // it would be two processes on one store; exiting instead leaves
6436
+ // the service manager, where there is one, to bring it back.
6437
+ 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`);
6438
+ }
6439
+ else {
6440
+ log(`restarting stratusd${restart.reason ? ` (${restart.reason})` : ''}`);
6441
+ }
6442
+ }
6443
+ else {
6444
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
6445
+ }
5359
6446
  }
5360
6447
  finally {
5361
- if (redirectTimer) {
5362
- clearInterval(redirectTimer);
6448
+ try {
6449
+ await gateway.stop();
6450
+ // Writes are queued and dropped on the hot path so logging never sits
6451
+ // on a streamed token. That makes the tail of the log the part most
6452
+ // likely to be lost — and the tail is the shutdown reason, the last
6453
+ // warning, the line explaining a restart.
6454
+ await logWriter?.flush();
5363
6455
  }
5364
- // Writes are queued and dropped on the hot path so logging never sits
5365
- // on a streamed token. That makes the tail of the log the part most
5366
- // likely to be lost — and the tail is the shutdown reason, the last
5367
- // warning, the line explaining a restart.
5368
- await logWriter?.flush();
5369
- }
6456
+ finally {
6457
+ // The shutdown is over, the flush included: a stop signal from here
6458
+ // on is the process's to handle by default again — and a host that
6459
+ // calls runServe repeatedly (the tests) must not accumulate handlers.
6460
+ // Last, and under its own finally, because the listener is on the
6461
+ // global process: taken off before the flush, a repeated signal
6462
+ // landing during it would end the process by the default action
6463
+ // (which a supervisor reports as a failure, and a service manager
6464
+ // set to restart on failure would then bring back a daemon an
6465
+ // operator just stopped); left on by a stop() that rejects (its last
6466
+ // log line can throw through an injected stream), a settled handler
6467
+ // would swallow the host's next signal.
6468
+ if (stopSignal) {
6469
+ process.off('SIGTERM', stopSignal);
6470
+ process.off('SIGINT', stopSignal);
6471
+ }
6472
+ if (redirectTimer) {
6473
+ clearInterval(redirectTimer);
6474
+ }
6475
+ }
6476
+ }
6477
+ const bound = boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0
6478
+ ? { boundApiPort }
6479
+ : {};
6480
+ if (!restart || stopRequested) {
6481
+ return { code: 0, ...bound, sessions: [] };
6482
+ }
6483
+ if (!restart.drained) {
6484
+ (env.exitProcess ?? process.exit)(UNDRAINED_RESTART_EXIT_CODE);
6485
+ return { code: UNDRAINED_RESTART_EXIT_CODE, ...bound, sessions: [] };
6486
+ }
6487
+ // Coming back: the browser sessions live at the stop go to the
6488
+ // replacement — up the channel to the supervisor when this daemon has
6489
+ // one, else with the outcome to the supervisor this process becomes.
6490
+ const sessions = boundApi?.sessionsAtStop() ?? [];
6491
+ if (supervised && sessions.length > 0) {
6492
+ link.send({ type: 'stratusd.sessions', sessions });
6493
+ }
6494
+ // Asked to come back. Whether this process starts the next daemon or
6495
+ // exits for its supervisor to is `runServe`'s decision, after the home
6496
+ // is released: the claim is held until this returns, and a daemon
6497
+ // started before that would be refused as a second one on the home.
6498
+ return { code: RESTART_EXIT_CODE, ...bound, sessions };
5370
6499
  };
5371
6500
  export const runCli = async ({ argv, streams = process, env = {} }) => {
5372
6501
  try {
@@ -5405,6 +6534,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
5405
6534
  || command.command === 'run'
5406
6535
  || command.command === 'skill-add'
5407
6536
  || command.command === 'dashboard'
6537
+ || (command.command === 'credential' && command.action !== 'list')
5408
6538
  || (command.command === 'schedules' && command.action === 'cancel')
5409
6539
  || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
5410
6540
  if (writesState) {
@@ -5444,9 +6574,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
5444
6574
  if (command.command === 'skill-add') {
5445
6575
  return await runSkillAdd(command, streams, resolvedEnv);
5446
6576
  }
6577
+ if (command.command === 'skill-validate') {
6578
+ return await runSkillValidate(command, streams, resolvedEnv);
6579
+ }
5447
6580
  if (command.command === 'skills') {
5448
6581
  return await runSkills(streams, resolvedEnv);
5449
6582
  }
6583
+ if (command.command === 'credential') {
6584
+ return await runCredential(command, streams, resolvedEnv);
6585
+ }
6586
+ if (command.command === 'skill-reload') {
6587
+ return await runSkillReload(command, streams, resolvedEnv);
6588
+ }
6589
+ if (command.command === 'restart') {
6590
+ return await runRestart(command, streams, resolvedEnv);
6591
+ }
5450
6592
  if (command.command === 'schedules') {
5451
6593
  return await runSchedules(command, streams, resolvedEnv);
5452
6594
  }