@stratusagent/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,18 +8,19 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { spawn } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
12
13
  import os from 'node:os';
13
14
  import path from 'node:path';
14
15
  import { createInterface } from 'node:readline';
15
- import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, } from '@stratusagent/core';
16
+ import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, originOf, } from '@stratusagent/core';
16
17
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
17
18
  import { loadPlugins } from '@stratusagent/plugins';
18
- import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
19
+ import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, describeOriginScope, } from '@stratusagent/permissions';
19
20
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
20
21
  import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
21
- import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
22
- import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
22
+ import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, isLoadableSkillId, isValidAgentId, parseSoul, } from '@stratusagent/agents';
23
+ import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileCredentialResolver, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadNamedCredentials, discoverSkillsInDirectory, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, saveNamedCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
23
24
  import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
24
25
  export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
25
26
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -123,7 +124,16 @@ const defaultPackageInstaller = async (packages) => {
123
124
  });
124
125
  });
125
126
  };
126
- export const CLI_VERSION = '0.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.0';
127
137
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
128
138
  /**
129
139
  * The range every package declares in its `engines` field. Two floors, not
@@ -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);
@@ -3150,9 +3421,10 @@ export const runSetup = async (command, streams, env = {}) => {
3150
3421
  if (chosen.length > 0) {
3151
3422
  const packages = chosen.flatMap((group) => group.packages);
3152
3423
  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.
3424
+ // Never fails setup — not even its exit code, unlike the always-on
3425
+ // service step below: the config and credentials are already written,
3426
+ // and a package that did not install is a warning at the next start,
3427
+ // not a broken machine.
3156
3428
  const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
3157
3429
  .catch((error) => ({
3158
3430
  ok: false,
@@ -3181,20 +3453,24 @@ export const runSetup = async (command, streams, env = {}) => {
3181
3453
  };
3182
3454
  /**
3183
3455
  * Write everything the menu decided, then report whether the always-on
3184
- * install it performed succeeded.
3456
+ * service step it performed — installing the service, or removing one the
3457
+ * user chose not to run — succeeded.
3185
3458
  *
3186
- * A boolean rather than a throw: the install is the one optional part of
3459
+ * A boolean rather than a throw: the service is the one optional part of
3187
3460
  * setup and everything else is already on disk by the time it runs, so
3188
3461
  * 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.
3462
+ * installed (or removed) later. The exit code carries it instead — the
3463
+ * same answer `stratus service install` and `stratus service uninstall`
3464
+ * give for the identical failure, which is what a script driving setup
3465
+ * has to be able to see.
3192
3466
  */
3193
3467
  const save = async () => {
3194
- // The always-on install is the only step here that can fail without
3468
+ // The always-on service step is the only one here that can fail without
3195
3469
  // taking the rest of setup with it, so it is the only one the exit code
3196
- // has to carry.
3197
- let serviceInstallFailed = false;
3470
+ // has to carry. Both signs count: a unit left in place after "do not run
3471
+ // it for me" starts a daemon at login the user asked not to have, which
3472
+ // is no more a successful setup than one that will not come up at all.
3473
+ let serviceStepFailed = false;
3198
3474
  const config = { provider: state.provider };
3199
3475
  if (state.provider !== 'demo') {
3200
3476
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -3287,6 +3563,9 @@ export const runSetup = async (command, streams, env = {}) => {
3287
3563
  for (const message of removed.messages) {
3288
3564
  writeLine(removed.ok ? streams.stdout : streams.stderr, message);
3289
3565
  }
3566
+ if (!removed.ok) {
3567
+ serviceStepFailed = true;
3568
+ }
3290
3569
  }
3291
3570
  }
3292
3571
  else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
@@ -3312,7 +3591,7 @@ export const runSetup = async (command, streams, env = {}) => {
3312
3591
  }
3313
3592
  if (!result.ok) {
3314
3593
  writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
3315
- serviceInstallFailed = true;
3594
+ serviceStepFailed = true;
3316
3595
  }
3317
3596
  }
3318
3597
  writeLine(streams.stdout);
@@ -3363,7 +3642,7 @@ export const runSetup = async (command, streams, env = {}) => {
3363
3642
  if (dashboardReady) {
3364
3643
  writeLine(streams.stdout, ' stratus dashboard');
3365
3644
  }
3366
- return !serviceInstallFailed;
3645
+ return !serviceStepFailed;
3367
3646
  };
3368
3647
  try {
3369
3648
  if (!interactive) {
@@ -3407,11 +3686,12 @@ export const runSetup = async (command, streams, env = {}) => {
3407
3686
  await testRun();
3408
3687
  }
3409
3688
  }
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.
3689
+ // Non-zero when the always-on service step failed, matching `stratus
3690
+ // service install` and `stratus service uninstall`. Everything setup
3691
+ // saved is still saved, and the output above says so — but a daemon that
3692
+ // will not come up at login, or one that will after the user said not to
3693
+ // run it, is not a successful setup, and a script that only reads the
3694
+ // exit code had no way to tell.
3415
3695
  return (await save()) ? 0 : 1;
3416
3696
  }
3417
3697
  finally {
@@ -4461,6 +4741,18 @@ export const runSkillAdd = async (command, streams, env = {}) => {
4461
4741
  });
4462
4742
  for (const skill of result.installed) {
4463
4743
  writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
4744
+ // The spec's compatibility field is written for exactly this reader:
4745
+ // the person deciding whether this environment can carry the skill.
4746
+ if (skill.compatibility !== undefined) {
4747
+ writeLine(streams.stdout, ` compatibility: ${skill.compatibility}`);
4748
+ }
4749
+ }
4750
+ // What installed with a caveat — a field another host owns, the
4751
+ // legacy key form, a bundled scripts/ — is said next to the install,
4752
+ // per skill: the operator deciding to enable it is the one who needs
4753
+ // to hear it, and this is the moment they are looking.
4754
+ for (const warning of result.warnings) {
4755
+ writeLine(streams.stderr, `Warning: ${warning.id}: ${warning.message}`);
4464
4756
  }
4465
4757
  // Present already is a no-op, not a failure — and exactly what the
4466
4758
  // "rerun with --agent" hint below produces, so these stay eligible
@@ -4477,6 +4769,13 @@ export const runSkillAdd = async (command, streams, env = {}) => {
4477
4769
  : `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
4478
4770
  return 1;
4479
4771
  }
4772
+ // The ordinary path needs no second step: a daemon that is running
4773
+ // serves what was just installed from its next turn. Only when
4774
+ // something changed on disk — a re-run that installed nothing has
4775
+ // nothing to reload — and only where a daemon has said it is running.
4776
+ if (result.installed.length > 0 && command.reload !== false) {
4777
+ await reloadRunningDaemonSkills(streams, env);
4778
+ }
4480
4779
  // Installed is not enabled: a soul opts in through its skills:
4481
4780
  // allowlist, and that stays true when the install came through a
4482
4781
  // command. --agent is the explicit way to say both at once.
@@ -4564,6 +4863,279 @@ export const runSchedules = async (command, streams, env = {}) => {
4564
4863
  store.close();
4565
4864
  }
4566
4865
  };
4866
+ /**
4867
+ * `stratus credential set|list|remove`: the place a named credential goes.
4868
+ *
4869
+ * A credential nobody can add is a credential nobody has, which is why this
4870
+ * exists at all — a search backend asks for `search.apiKey` and until now
4871
+ * there was nowhere to put one. Three rules it keeps, all of them
4872
+ * deliberate:
4873
+ *
4874
+ * The value is read from **stdin, never from a flag**. A secret in argv is
4875
+ * a secret in shell history and in every `ps` on the machine.
4876
+ *
4877
+ * Nothing here ever prints a value back. `list` reports names and which
4878
+ * agents have their own, the same posture the control API's credential read
4879
+ * already keeps.
4880
+ *
4881
+ * And storing one grants no agent anything: the agent's soul still has to
4882
+ * list the name under `credentials:`, which is the per-identity gate.
4883
+ */
4884
+ export const runCredential = async (command, streams, env = {}) => {
4885
+ const named = await loadNamedCredentials(env);
4886
+ if (command.action === 'list') {
4887
+ const shared = Object.keys(named.shared).sort();
4888
+ const agents = Object.entries(named.agents).sort(([a], [b]) => a.localeCompare(b));
4889
+ if (shared.length === 0 && agents.length === 0) {
4890
+ writeLine(streams.stdout, `No named credentials stored in ${credentialsPath(env)}.`);
4891
+ writeLine(streams.stdout, 'A search backend wants `stratus credential set search.apiKey`.');
4892
+ return 0;
4893
+ }
4894
+ if (shared.length > 0) {
4895
+ writeLine(streams.stdout, 'Shared with the whole fleet:');
4896
+ for (const name of shared) {
4897
+ writeLine(streams.stdout, ` ${name}`);
4898
+ }
4899
+ }
4900
+ for (const [agentId, entries] of agents) {
4901
+ writeLine(streams.stdout, `Only ${agentId}:`);
4902
+ for (const name of Object.keys(entries).sort()) {
4903
+ writeLine(streams.stdout, ` ${name}`);
4904
+ }
4905
+ }
4906
+ // Names, never values — and say so, so nobody goes looking for a flag
4907
+ // that prints one.
4908
+ writeLine(streams.stdout, '');
4909
+ writeLine(streams.stdout, `Values are never printed. They live in ${credentialsPath(env)}, readable only by you.`);
4910
+ return 0;
4911
+ }
4912
+ const name = command.name ?? '';
4913
+ if (command.action === 'remove') {
4914
+ const scope = command.agentId;
4915
+ const store = scope === undefined ? named.shared : named.agents[scope];
4916
+ if (!store || store[name] === undefined) {
4917
+ writeLine(streams.stderr, scope === undefined
4918
+ ? `No shared credential named ${name}. \`stratus credentials\` lists what is stored.`
4919
+ : `Agent ${scope} has no credential of its own named ${name}. \`stratus credentials\` lists what is stored.`);
4920
+ return 1;
4921
+ }
4922
+ delete store[name];
4923
+ if (scope !== undefined && Object.keys(store).length === 0) {
4924
+ delete named.agents[scope];
4925
+ }
4926
+ await saveNamedCredentials(env, named);
4927
+ writeLine(streams.stdout, scope === undefined ? `Removed ${name}.` : `Removed ${name} for ${scope}.`);
4928
+ return 0;
4929
+ }
4930
+ writeLine(streams.stderr, `Reading the value for ${name} from stdin — it is never taken from the command line, where it would land in your shell history. Ctrl-D when done.`);
4931
+ const arrived = env.stdin ?? await readSecretFromStdin(env.stdinStream ?? process.stdin);
4932
+ // One trailing line terminator and nothing else. `echo "$KEY" |` appends a
4933
+ // newline that is not part of the key and `printf %s` appends nothing, so
4934
+ // both spellings have to work — while a key whose own value ends in a
4935
+ // space must survive either of them, which a blanket trim would not allow.
4936
+ const value = arrived.replace(/\r?\n$/, '');
4937
+ if (value.trim().length === 0) {
4938
+ writeLine(streams.stderr, `Nothing arrived on stdin, so ${name} was not stored. Pipe the value in: \`printf %s "$KEY" | stratus credential set ${name}\`.`);
4939
+ return 1;
4940
+ }
4941
+ if (command.agentId === undefined) {
4942
+ named.shared[name] = value;
4943
+ }
4944
+ else {
4945
+ named.agents[command.agentId] = { ...(named.agents[command.agentId] ?? {}), [name]: value };
4946
+ }
4947
+ await saveNamedCredentials(env, named);
4948
+ writeLine(streams.stdout, command.agentId === undefined
4949
+ ? `Stored ${name} in ${credentialsPath(env)} (readable only by you).`
4950
+ : `Stored ${name} for ${command.agentId} in ${credentialsPath(env)} (readable only by you). It outranks the shared entry for that agent.`);
4951
+ // Storing grants nothing: the soul is the second gate, and an operator
4952
+ // who stops here gets "not allowed to access credential" on every call.
4953
+ writeLine(streams.stdout, `Each agent that may use it still needs \`credentials: [${name}]\` in its soul.`);
4954
+ return 0;
4955
+ };
4956
+ /**
4957
+ * `stratus skill validate <target>`: the install-time check, run without
4958
+ * installing — for an author about to publish a skill, or an operator
4959
+ * asking why one was refused. A local path is a skill directory or a
4960
+ * directory of skills, discovered exactly as `skill add` would; a bare id
4961
+ * names an installed skill under `~/.stratus/skills/`. Exit 1 when
4962
+ * anything would be refused, so a publish step can gate on it.
4963
+ */
4964
+ export const runSkillValidate = async (command, streams, env = {}) => {
4965
+ let directory;
4966
+ let installed = false;
4967
+ const localPath = path.resolve(readWorkingDirectory(env), command.target);
4968
+ try {
4969
+ if ((await stat(localPath)).isDirectory()) {
4970
+ directory = localPath;
4971
+ }
4972
+ }
4973
+ catch {
4974
+ // Not a local directory; try it as an installed id.
4975
+ }
4976
+ // The loader's rule for the lookup, so a directory that loads with a
4977
+ // pre-spec id can be validated — and told what to rename.
4978
+ if (directory === undefined && isLoadableSkillId(command.target)) {
4979
+ const installedPath = path.join(skillsDirPath(env), command.target);
4980
+ try {
4981
+ if ((await stat(installedPath)).isDirectory()) {
4982
+ directory = installedPath;
4983
+ installed = true;
4984
+ }
4985
+ }
4986
+ catch {
4987
+ // Not installed either.
4988
+ }
4989
+ }
4990
+ if (directory === undefined) {
4991
+ writeLine(streams.stderr, `Error: ${JSON.stringify(command.target)} is neither a directory nor an installed skill id (stratus skills lists those).`);
4992
+ return 1;
4993
+ }
4994
+ // An installed directory IS the layout the spec's directory rule is
4995
+ // about, so its name is checked; a path the author typed is a checkout
4996
+ // or a container, judged as `skill add` would judge it.
4997
+ const { candidates, skipped } = await discoverSkillsInDirectory(directory, installed ? { checkRootDirectoryName: true } : {});
4998
+ if (candidates.length === 0 && skipped.length === 0) {
4999
+ writeLine(streams.stderr, `Error: no skills found in ${directory}. A skill is a directory with a SKILL.md.`);
5000
+ return 1;
5001
+ }
5002
+ for (const candidate of candidates) {
5003
+ const caveat = candidate.warnings.length > 0
5004
+ ? ` — ${candidate.warnings.length} warning${candidate.warnings.length === 1 ? '' : 's'}`
5005
+ : '';
5006
+ writeLine(streams.stdout, `${candidate.id}: ok${caveat}`);
5007
+ for (const warning of candidate.warnings) {
5008
+ writeLine(streams.stdout, ` warning: ${warning}`);
5009
+ }
5010
+ }
5011
+ for (const skip of skipped) {
5012
+ writeLine(streams.stdout, `${skip.id}: refused`);
5013
+ writeLine(streams.stdout, ` error: ${skip.reason}`);
5014
+ }
5015
+ if (skipped.length > 0) {
5016
+ const noun = skipped.length === 1 ? 'skill' : 'skills';
5017
+ writeLine(streams.stderr, `Error: ${skipped.length} ${noun} would be refused at install.`);
5018
+ return 1;
5019
+ }
5020
+ return 0;
5021
+ };
5022
+ /**
5023
+ * The control API base a command should talk to: `--gateway` when given,
5024
+ * else whatever daemon `~/.stratus/gateway.json` says is serving, else
5025
+ * nothing — the file is written when the API binds and removed when it
5026
+ * stops, so its absence means no daemon has said where it is.
5027
+ */
5028
+ const runningGatewayBase = async (env, target) => {
5029
+ if (target.gateway) {
5030
+ return target.gateway.replace(/\/+$/, '');
5031
+ }
5032
+ const info = await readGatewayInfo(env);
5033
+ return info?.url.replace(/\/+$/, '');
5034
+ };
5035
+ /** One authenticated call to a daemon's control API, with the network and auth failures said plainly. */
5036
+ const callRunningGateway = async (env, target, base, pathname, body) => {
5037
+ const token = await gatewayToken(env, target.token);
5038
+ const fetchImpl = env.fetch ?? globalThis.fetch;
5039
+ if (typeof fetchImpl !== 'function') {
5040
+ throw new Error('fetch is unavailable, so this runtime cannot reach a daemon.');
5041
+ }
5042
+ let response;
5043
+ try {
5044
+ response = await fetchImpl(`${base}${pathname}`, {
5045
+ method: 'POST',
5046
+ headers: {
5047
+ authorization: `Bearer ${token}`,
5048
+ ...(body ? { 'content-type': 'application/json' } : {}),
5049
+ },
5050
+ ...(body ? { body: JSON.stringify(body) } : {}),
5051
+ });
5052
+ }
5053
+ catch (error) {
5054
+ throw new Error(`Could not reach the gateway at ${base} (${error instanceof Error ? error.message : String(error)}). `
5055
+ + 'Is stratusd running, and does it have @stratusagent/control-api installed?');
5056
+ }
5057
+ if (response.status === 401 || response.status === 403) {
5058
+ throw new Error(`The gateway at ${base} rejected this token. Check --token, STRATUS_GATEWAY_TOKEN, or ~/.stratus/gateway-token.`);
5059
+ }
5060
+ return response;
5061
+ };
5062
+ /** The API's own sentence for a failure, or the status when it sent none. */
5063
+ const gatewayErrorMessage = async (response) => {
5064
+ try {
5065
+ const payload = await response.json();
5066
+ if (payload.error?.message) {
5067
+ return payload.error.message;
5068
+ }
5069
+ }
5070
+ catch {
5071
+ // Not JSON; the status is all there is to say.
5072
+ }
5073
+ return `HTTP ${response.status}`;
5074
+ };
5075
+ const noRunningDaemonMessage = (env) => `no running daemon found — ${gatewayInfoPath(env)} does not exist, and no --gateway was given. `
5076
+ + 'Start one with `stratus serve` or `stratus service start`; a daemon loads ~/.stratus/skills at start.';
5077
+ /**
5078
+ * After `skill add`: tell the daemon, if one says it is running. Silent
5079
+ * when none does — a `stratus run` user with no daemon should not read a
5080
+ * note about one — and a warning, never a failure, when the file names a
5081
+ * daemon that did not answer: the install itself succeeded.
5082
+ */
5083
+ const reloadRunningDaemonSkills = async (streams, env) => {
5084
+ const info = await readGatewayInfo(env);
5085
+ if (!info) {
5086
+ return;
5087
+ }
5088
+ const base = info.url.replace(/\/+$/, '');
5089
+ try {
5090
+ const response = await callRunningGateway(env, {}, base, '/api/v1/skills/reload');
5091
+ if (!response.ok) {
5092
+ throw new Error(await gatewayErrorMessage(response));
5093
+ }
5094
+ writeLine(streams.stdout, `reloaded the running daemon's skills (${base}) — no restart needed`);
5095
+ }
5096
+ catch (error) {
5097
+ writeLine(streams.stderr, `Warning: ${gatewayInfoPath(env)} names a daemon at ${base}, but its skills were not reloaded: `
5098
+ + `${error instanceof Error ? error.message : String(error)} If it is running, reload it with: stratus skill reload`);
5099
+ }
5100
+ };
5101
+ export const runSkillReload = async (command, streams, env = {}) => {
5102
+ const base = await runningGatewayBase(env, command);
5103
+ if (!base) {
5104
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5105
+ return 1;
5106
+ }
5107
+ const response = await callRunningGateway(env, command, base, '/api/v1/skills/reload');
5108
+ if (!response.ok) {
5109
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5110
+ return 1;
5111
+ }
5112
+ const { skills } = await response.json();
5113
+ const ids = (skills ?? []).map((skill) => skill.id);
5114
+ writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
5115
+ return 0;
5116
+ };
5117
+ export const runRestart = async (command, streams, env = {}) => {
5118
+ const base = await runningGatewayBase(env, command);
5119
+ if (!base) {
5120
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5121
+ return 1;
5122
+ }
5123
+ const response = await callRunningGateway(env, command, base, '/api/v1/restart', {
5124
+ reason: command.reason ?? 'stratus restart',
5125
+ ...(command.drainTimeoutMs !== undefined ? { drainTimeoutMs: command.drainTimeoutMs } : {}),
5126
+ });
5127
+ if (!response.ok) {
5128
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5129
+ return 1;
5130
+ }
5131
+ const status = await response.json();
5132
+ const inflight = status.inflight ?? 0;
5133
+ const window = Math.round((status.drainTimeoutMs ?? 0) / 1000);
5134
+ writeLine(streams.stdout, `restart announced to the daemon at ${base} — new turns are refused; ${inflight} turn(s) in flight `
5135
+ + `get up to ${window}s to finish, then it comes back.`);
5136
+ writeLine(streams.stdout, 'Watch it come back: stratus logs -f');
5137
+ return 0;
5138
+ };
4567
5139
  export const runSkills = async (streams, env = {}) => {
4568
5140
  const registry = new SkillRegistry();
4569
5141
  const skills = await loadOperatorSkills(env, registry, (line) => {
@@ -4808,6 +5380,85 @@ const gatewayAnswering = async (env, base, fetchImpl) => {
4808
5380
  return false;
4809
5381
  }
4810
5382
  };
5383
+ /**
5384
+ * Whether a process with this pid exists. EPERM is an answer — the process
5385
+ * is there, it is just not ours to signal.
5386
+ */
5387
+ const processAlive = (pid) => {
5388
+ try {
5389
+ process.kill(pid, 0);
5390
+ return true;
5391
+ }
5392
+ catch (error) {
5393
+ return error.code === 'EPERM';
5394
+ }
5395
+ };
5396
+ /**
5397
+ * The error for a home another daemon holds (see `claimHome` in
5398
+ * @stratusagent/gateway — the claim is what decides; this only says who).
5399
+ *
5400
+ * The discovery file names the holder when it has one to name: a daemon
5401
+ * that has bound its API. Read for the message alone, and only trusted as
5402
+ * far as a live pid — a daemon still starting has not written it, a
5403
+ * daemon draining its last turn has already removed it, and a SIGKILLed
5404
+ * one leaves it behind for the next pid to inherit. Not probed over HTTP:
5405
+ * a `STRATUS_GATEWAY_TOKEN` exported for some remote gateway would answer
5406
+ * 401 for the local one, and the claim needs no second opinion.
5407
+ */
5408
+ /**
5409
+ * `stratus serve` refused because another daemon holds the home. Its own
5410
+ * type so `stratus dashboard` can tell "a daemon is there, wait for it to
5411
+ * publish" from "the daemon could not start".
5412
+ */
5413
+ class HomeHeldError extends Error {
5414
+ constructor(message) {
5415
+ super(message);
5416
+ this.name = 'HomeHeldError';
5417
+ }
5418
+ }
5419
+ /**
5420
+ * Whether a daemon from before the lock existed is serving this home.
5421
+ *
5422
+ * Such a daemon — alive across an upgrade that did not stop it — never
5423
+ * took the claim, so the claim alone cannot see it. The discovery file it
5424
+ * published is its only trace, held to the two proofs the lock made
5425
+ * unnecessary for everything since: the pid it names is alive, and its
5426
+ * URL answers `/health` with this home's own token file — never the
5427
+ * `STRATUS_GATEWAY_TOKEN` override, which names some other gateway and
5428
+ * would make a live daemon read as absent. A stale file fails either
5429
+ * proof and refuses nothing.
5430
+ */
5431
+ const legacyDaemonServing = async (env) => {
5432
+ const info = await readGatewayInfo(env);
5433
+ if (info?.pid === undefined || info.pid === process.pid || !processAlive(info.pid)) {
5434
+ return false;
5435
+ }
5436
+ const fetchImpl = env.fetch ?? globalThis.fetch;
5437
+ if (typeof fetchImpl !== 'function') {
5438
+ return false;
5439
+ }
5440
+ try {
5441
+ const token = (await readFile(gatewayTokenPath(env), 'utf8')).trim();
5442
+ const response = await fetchImpl(`${info.url}/api/v1/health`, {
5443
+ headers: { authorization: `Bearer ${token}` },
5444
+ signal: AbortSignal.timeout(2_000),
5445
+ });
5446
+ return response.ok;
5447
+ }
5448
+ catch {
5449
+ return false;
5450
+ }
5451
+ };
5452
+ const describeHeldHome = async (env) => {
5453
+ const info = await readGatewayInfo(env);
5454
+ const holder = info?.pid !== undefined && processAlive(info.pid)
5455
+ ? ` (pid ${info.pid}, ${info.url})`
5456
+ : ' — one that is still starting, or still draining its last turns';
5457
+ return `stratusd is already running for this home${holder}. Two daemons on one ~/.stratus `
5458
+ + 'would each fire the other\'s schedules and re-ask its approvals. Stop it first: `stratus service stop` '
5459
+ + 'if it is the installed service, otherwise Ctrl+C where it runs — and let it finish; it holds the home until '
5460
+ + 'its last turn is written.';
5461
+ };
4811
5462
  /**
4812
5463
  * `stratus dashboard` — open the web UI against a running daemon, starting
4813
5464
  * one in the foreground when there is none.
@@ -4830,56 +5481,87 @@ export const runDashboard = async (command, streams, env = {}) => {
4830
5481
  if (!base) {
4831
5482
  writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
4832
5483
  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.
5484
+ // One attempt at a daemon of our own. `runServe` can reject before it
5485
+ // publishes anything — an unreadable roster, a session store that will
5486
+ // not open — and a rejected promise nobody is watching for fifteen
5487
+ // seconds is an unhandled rejection, which terminates the process
5488
+ // instead of reaching the message below. Captured rather than
5489
+ // swallowed, so the reason the daemon gave is the reason this command
5490
+ // reports.
4858
5491
  let daemonFailure;
4859
- const daemonExit = serving.then(() => undefined, (error) => { daemonFailure = error; });
4860
- // Gated on the daemon actually answering, not on a delay: it publishes
5492
+ let daemonSettled = false;
5493
+ let daemonExit = Promise.resolve();
5494
+ let attemptedAt = 0;
5495
+ const attempt = () => {
5496
+ daemonFailure = undefined;
5497
+ daemonSettled = false;
5498
+ attemptedAt = Date.now();
5499
+ serving = runServe({
5500
+ command: 'serve',
5501
+ events: false,
5502
+ // Explicitly on. A trusted config may set `api.enabled: false` —
5503
+ // a reasonable thing for a headless box — but this command exists
5504
+ // to open the dashboard, and honouring it here would start a
5505
+ // daemon with no API and then time out waiting for the one it
5506
+ // promised.
5507
+ api: true,
5508
+ ...(command.port !== undefined ? { apiPort: command.port } : {}),
5509
+ // Passed whenever it was asked for, including when it is the
5510
+ // default. `--host 127.0.0.1` against a config saying `0.0.0.0`
5511
+ // is an operator narrowing the bind, and dropping it because it
5512
+ // matched the default did the reverse of what they typed.
5513
+ ...(command.host !== undefined ? { apiHost: command.host } : {}),
5514
+ },
5515
+ // The daemon's own chatter belongs on stderr here: stdout is where
5516
+ // this command says where to point a browser, and interleaving the
5517
+ // two makes the one line that matters hard to find.
5518
+ { stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
5519
+ daemonExit = serving.then(() => { daemonSettled = true; }, (error) => { daemonFailure = error; daemonSettled = true; });
5520
+ };
5521
+ attempt();
5522
+ // Gated on a daemon actually answering, not on a delay: one publishes
4861
5523
  // 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.
5524
+ // dressed up as a timeout. It also stops the moment our daemon gives up
5525
+ // — except when it gave up because another one holds the home. An
5526
+ // installed service still starting has not published its address yet
5527
+ // and is about to: that is the daemon to wait for. And a daemon still
5528
+ // draining may simply exit, with nothing replacing it — so while the
5529
+ // home is held, our own attempt is repeated about once a second, and
5530
+ // takes the home the moment it is free.
4864
5531
  const startedBy = Date.now() + 15_000;
4865
- let daemonStopped = false;
4866
- void daemonExit.then(() => { daemonStopped = true; });
4867
- while (!base && !daemonStopped && Date.now() < startedBy) {
5532
+ while (!base && Date.now() < startedBy) {
4868
5533
  const info = await readGatewayInfo(env);
4869
5534
  if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
4870
5535
  base = info.url;
4871
5536
  break;
4872
5537
  }
5538
+ if (daemonSettled) {
5539
+ if (!(daemonFailure instanceof HomeHeldError)) {
5540
+ break;
5541
+ }
5542
+ if (Date.now() - attemptedAt >= 1_000) {
5543
+ attempt();
5544
+ }
5545
+ }
4873
5546
  await new Promise((resolve) => setTimeout(resolve, 150));
4874
5547
  }
4875
5548
  if (!base) {
4876
5549
  ownDaemon.abort();
4877
5550
  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?');
5551
+ writeLine(streams.stderr, daemonFailure instanceof HomeHeldError
5552
+ ? 'Error: another daemon holds this home but never published its address. It may still be starting or '
5553
+ + 'draining — try again in a moment, or check `stratus service status`.'
5554
+ : daemonFailure
5555
+ ? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
5556
+ : 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
4881
5557
  return 1;
4882
5558
  }
5559
+ if (daemonFailure instanceof HomeHeldError) {
5560
+ // The daemon that answered is the one that held the home. Ours never
5561
+ // started, so there is nothing of ours to wait for or to stop: from
5562
+ // here this is the found-a-running-daemon case.
5563
+ serving = undefined;
5564
+ }
4883
5565
  }
4884
5566
  let url;
4885
5567
  try {
@@ -4915,7 +5597,15 @@ export const runDashboard = async (command, streams, env = {}) => {
4915
5597
  await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
4916
5598
  ownDaemon.abort();
4917
5599
  }
4918
- return serving;
5600
+ // An attempt still in flight when the daemon answered may yet be refused
5601
+ // — the holder published while ours was claiming. The dashboard found a
5602
+ // daemon; that refusal is not its failure.
5603
+ return serving.catch((error) => {
5604
+ if (error instanceof HomeHeldError) {
5605
+ return 0;
5606
+ }
5607
+ throw error;
5608
+ });
4919
5609
  };
4920
5610
  /**
4921
5611
  * Resolution and loading are separate questions, and only the first one
@@ -5018,6 +5708,214 @@ const loadServePlugins = async (env, configPath, warn) => {
5018
5708
  * roster, accept dispatches, print events, and drain cleanly on SIGTERM,
5019
5709
  * SIGINT, or the injected shutdown signal.
5020
5710
  */
5711
+ /**
5712
+ * What a daemon exits with to ask for a fresh process. Only ever seen by the
5713
+ * supervisor below, which is the only thing that starts a daemon with the
5714
+ * environment marker that makes it exit this way instead of supervising.
5715
+ */
5716
+ export const RESTART_EXIT_CODE = 75;
5717
+ /**
5718
+ * What a daemon exits with when a restart could not drain — a turn that
5719
+ * ignored its abort, a plugin or channel that did not let go. Distinct from
5720
+ * the restart status on purpose: the supervisor answers this one by
5721
+ * exiting with it too, so the process the service manager started ends
5722
+ * and the manager restarts the whole unit — under systemd, cleaning the
5723
+ * cgroup of whatever the plugin left running. A supervisor that started
5724
+ * another daemon instead would keep that cgroup, and its leaks, alive.
5725
+ */
5726
+ export const UNDRAINED_RESTART_EXIT_CODE = 76;
5727
+ /** Set in a daemon the supervisor started, so its own restart is an exit, not a second supervisor. */
5728
+ const SUPERVISED_ENV = 'STRATUS_SERVE_SUPERVISED';
5729
+ /**
5730
+ * The control API port the last daemon bound, handed to its replacement by
5731
+ * the supervisor. Honoured only where the replacement's own request — the
5732
+ * flag, else the config it reads afresh — is still for any free port: a
5733
+ * daemon that asked for port 0 comes back on the port it had, so a
5734
+ * dashboard page reconnects to it, while a config since edited to a fixed
5735
+ * port takes effect on that restart, as an `api` block change is meant to.
5736
+ * A hint, never a pin: passing the port back as `--api-port` would outrank
5737
+ * the config for every restart after.
5738
+ */
5739
+ const BOUND_API_PORT_ENV = 'STRATUS_SERVE_BOUND_API_PORT';
5740
+ const isDashboardSession = (value) => typeof value === 'object' && value !== null
5741
+ && typeof value.id === 'string'
5742
+ && typeof value.expiresAt === 'number'
5743
+ && typeof value.vouchedBy === 'string';
5744
+ /** Shape-checked, because the other end of an IPC channel is still another process. */
5745
+ const isSupervisorMessage = (value) => {
5746
+ if (typeof value !== 'object' || value === null) {
5747
+ return false;
5748
+ }
5749
+ const message = value;
5750
+ if (message.type === 'stratusd.bound-api-port') {
5751
+ return typeof message.port === 'number' && Number.isInteger(message.port) && message.port > 0;
5752
+ }
5753
+ if (message.type === 'stratusd.sessions') {
5754
+ return Array.isArray(message.sessions) && message.sessions.every(isDashboardSession);
5755
+ }
5756
+ return false;
5757
+ };
5758
+ /**
5759
+ * The IPC channel a supervisor opens when it spawns a daemon: what a
5760
+ * supervised daemon says up it (the port it bound, so the supervisor
5761
+ * carries *that* port to the next daemon rather than the first one's; the
5762
+ * dashboard sessions it hands on) and what comes down it. Where there is
5763
+ * no channel — the first daemon, or one a service manager started — there
5764
+ * is no one to say it to, and nothing is said.
5765
+ */
5766
+ const defaultSupervisorLink = {
5767
+ send(message) {
5768
+ if (typeof process.send === 'function' && process.channel) {
5769
+ process.send(message);
5770
+ }
5771
+ },
5772
+ receive(handler) {
5773
+ const channel = process.channel;
5774
+ if (!channel) {
5775
+ return;
5776
+ }
5777
+ process.on('message', (message) => {
5778
+ if (isSupervisorMessage(message)) {
5779
+ handler(message);
5780
+ }
5781
+ });
5782
+ // A 'message' listener refs the channel, which would keep this process
5783
+ // alive after its drain; the daemon's own work is what holds it open.
5784
+ channel.unref?.();
5785
+ },
5786
+ };
5787
+ /**
5788
+ * The parsed serve command as arguments again — what the fresh daemon is
5789
+ * started with. From the command, not from `process.argv`: `stratus
5790
+ * dashboard` runs the daemon through `runServe` with a command it built,
5791
+ * and the process's own arguments would start a second dashboard.
5792
+ */
5793
+ export const serveArgv = (command) => [
5794
+ 'serve',
5795
+ ...(command.configPath !== undefined ? ['--config', command.configPath] : []),
5796
+ ...(command.idleTimeoutMs !== undefined ? ['--idle-timeout', String(command.idleTimeoutMs / 1000)] : []),
5797
+ ...(command.approvals !== undefined ? ['--approvals', command.approvals] : []),
5798
+ ...(command.events ? [] : ['--no-events']),
5799
+ ...(command.logToFile === false ? ['--no-log-file'] : []),
5800
+ ...(command.api === false ? ['--no-api'] : command.api === true ? ['--api'] : []),
5801
+ ...(command.apiPort !== undefined ? ['--api-port', String(command.apiPort)] : []),
5802
+ ...(command.apiHost !== undefined ? ['--api-host', command.apiHost] : []),
5803
+ ];
5804
+ /**
5805
+ * The `bin` beside the module at `moduleUrl`, with that module's own
5806
+ * extension: `dist/index.js` respawns `dist/bin.js`, and a source checkout
5807
+ * running `src/index.ts` under type stripping respawns `src/bin.ts` — the
5808
+ * flags that made that possible travel in `process.execArgv`. Assuming
5809
+ * compiled output would hand a source checkout a file that does not exist,
5810
+ * and a daemon that never comes back.
5811
+ */
5812
+ export const restartEntrypoint = (moduleUrl) => {
5813
+ const modulePath = fileURLToPath(moduleUrl);
5814
+ return path.join(path.dirname(modulePath), `bin${path.extname(modulePath)}`);
5815
+ };
5816
+ /**
5817
+ * Run this CLI's own entrypoint as a child with the daemon's streams and
5818
+ * exit code, forwarding the signals a supervisor would otherwise swallow.
5819
+ *
5820
+ * A child, and the parent waits, rather than a detached process and an
5821
+ * exit: under systemd and launchd the process the manager started is the
5822
+ * service, and its exit ends the job — cgroup and all, on Linux — so the
5823
+ * daemon that received the restart has to stay the manager's process and
5824
+ * become the supervisor of the next one. That is what makes the same path
5825
+ * hold in the foreground and under `--no-login`, where nothing else would
5826
+ * bring a clean exit back.
5827
+ */
5828
+ const defaultServeRespawn = (env) => (argv, handoff) => new Promise((resolve, reject) => {
5829
+ const entrypoint = restartEntrypoint(import.meta.url);
5830
+ // The daemon's own streams, plus an IPC channel for what it and its
5831
+ // supervisor have to tell each other. See SupervisorLink.
5832
+ const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...argv], {
5833
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
5834
+ env: {
5835
+ ...process.env,
5836
+ [SUPERVISED_ENV]: '1',
5837
+ ...(handoff.boundApiPort !== undefined ? { [BOUND_API_PORT_ENV]: String(handoff.boundApiPort) } : {}),
5838
+ },
5839
+ });
5840
+ if (handoff.sessions.length > 0 && child.connected) {
5841
+ // Queued in the channel until the daemon listens, which it does
5842
+ // before its API is up; the API holds them until it can adopt them.
5843
+ // A send that fails (the daemon died before reading) is not this
5844
+ // supervisor's failure: the exit below reports what happened, and a
5845
+ // daemon that never took them simply comes up signed out.
5846
+ const sessions = { type: 'stratusd.sessions', sessions: handoff.sessions };
5847
+ child.send(sessions, () => undefined);
5848
+ }
5849
+ const forward = (signal) => () => {
5850
+ child.kill(signal);
5851
+ };
5852
+ const onTerm = forward('SIGTERM');
5853
+ const onInt = forward('SIGINT');
5854
+ const onAbort = () => {
5855
+ child.kill('SIGTERM');
5856
+ };
5857
+ process.on('SIGTERM', onTerm);
5858
+ process.on('SIGINT', onInt);
5859
+ env.shutdownSignal?.addEventListener('abort', onAbort, { once: true });
5860
+ const done = () => {
5861
+ process.off('SIGTERM', onTerm);
5862
+ process.off('SIGINT', onInt);
5863
+ env.shutdownSignal?.removeEventListener('abort', onAbort);
5864
+ };
5865
+ let reportedPort;
5866
+ let handedBack;
5867
+ child.on('message', (message) => {
5868
+ if (!isSupervisorMessage(message)) {
5869
+ return;
5870
+ }
5871
+ if (message.type === 'stratusd.bound-api-port') {
5872
+ reportedPort = message.port;
5873
+ }
5874
+ else {
5875
+ handedBack = message.sessions;
5876
+ }
5877
+ });
5878
+ child.once('error', (error) => {
5879
+ done();
5880
+ reject(error);
5881
+ });
5882
+ child.once('exit', (code, signal) => {
5883
+ done();
5884
+ // Killed by a signal is a stop, not a request to come back.
5885
+ resolve({
5886
+ code: code ?? (signal ? 1 : 0),
5887
+ ...(reportedPort !== undefined ? { boundApiPort: reportedPort } : {}),
5888
+ ...(handedBack !== undefined ? { sessions: handedBack } : {}),
5889
+ });
5890
+ });
5891
+ });
5892
+ /**
5893
+ * Keep starting daemons for as long as they exit asking for another; the
5894
+ * first exit that does not is this process's own — an undrained restart's
5895
+ * status included, which ends the supervisor rather than starting a daemon
5896
+ * beside what the last one could not release.
5897
+ */
5898
+ const superviseRestarts = async (command, first, streams, env) => {
5899
+ const respawn = env.serveRespawn ?? defaultServeRespawn(env);
5900
+ // The hand-off follows the daemons: the port each one said it bound is
5901
+ // what the next one is told (so a config edited to a fixed port and
5902
+ // back hands on the last address, not the first), and the sessions are
5903
+ // whatever the last one handed back — a daemon that handed nothing
5904
+ // hands nothing on.
5905
+ let handoff = first;
5906
+ let result;
5907
+ do {
5908
+ writeLine(streams.stdout, 'Restarting stratusd.');
5909
+ result = await respawn(serveArgv(command), handoff);
5910
+ handoff = {
5911
+ ...(result.boundApiPort !== undefined || handoff.boundApiPort !== undefined
5912
+ ? { boundApiPort: result.boundApiPort ?? handoff.boundApiPort }
5913
+ : {}),
5914
+ sessions: result.sessions ?? [],
5915
+ };
5916
+ } while (result.code === RESTART_EXIT_CODE);
5917
+ return result.code;
5918
+ };
5021
5919
  export const runServe = async (command, streams, env = {}) => {
5022
5920
  // First, before anything that can throw. A broken install makes the
5023
5921
  // gateway import below fail, the manager restarts, and the CLI's error
@@ -5030,7 +5928,46 @@ export const runServe = async (command, streams, env = {}) => {
5030
5928
  // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
5031
5929
  // command neither needs nor should pay for (Node still prints an
5032
5930
  // experimental warning for it).
5033
- const { createGateway } = await import('@stratusagent/gateway');
5931
+ const { createGateway, claimHome, HomeClaimedError } = await import('@stratusagent/gateway');
5932
+ // Before anything of this daemon's is written or opened: a second daemon
5933
+ // on this home is refused here, with no log line, no store, and no sweep
5934
+ // or firing of its own.
5935
+ let claim;
5936
+ try {
5937
+ claim = claimHome(env);
5938
+ }
5939
+ catch (error) {
5940
+ if (error instanceof HomeClaimedError) {
5941
+ throw new HomeHeldError(await describeHeldHome(env));
5942
+ }
5943
+ throw error;
5944
+ }
5945
+ let outcome;
5946
+ try {
5947
+ // The one daemon the claim cannot see: one that predates it.
5948
+ if (await legacyDaemonServing(env)) {
5949
+ throw new HomeHeldError(await describeHeldHome(env));
5950
+ }
5951
+ outcome = await serveHeldHome(command, streams, env, createGateway);
5952
+ }
5953
+ finally {
5954
+ // After everything: the store closed by stop(), or whatever a throw
5955
+ // reached — a preflight that rejected before the gateway existed
5956
+ // included, so a host that calls runServe again is not refused for a
5957
+ // daemon that never started. And never sooner, so a replacement cannot
5958
+ // open the store while this one still writes.
5959
+ claim.release();
5960
+ }
5961
+ // An announced restart, once the home is let go — the replacement claims
5962
+ // it next. A daemon the supervisor started asks it for the next one by
5963
+ // exiting; the first daemon becomes the supervisor itself. Either way the
5964
+ // process the service manager (or the terminal) started is what stays.
5965
+ if (outcome.code === RESTART_EXIT_CODE && !readProcessEnv(env)[SUPERVISED_ENV]) {
5966
+ return superviseRestarts(command, { ...(outcome.boundApiPort !== undefined ? { boundApiPort: outcome.boundApiPort } : {}), sessions: outcome.sessions }, streams, env);
5967
+ }
5968
+ return outcome.code;
5969
+ };
5970
+ const serveHeldHome = async (command, streams, env, createGateway) => {
5034
5971
  // Under a service manager the daemon's stdout is gone, so everything it
5035
5972
  // says is also written to ~/.stratus/logs — that file is what `stratus
5036
5973
  // logs` reads, and the only record of an overnight run.
@@ -5069,6 +6006,8 @@ export const runServe = async (command, streams, env = {}) => {
5069
6006
  // Typed as the seam, not as whatever the first push happens to be: the
5070
6007
  // list holds the control API and the Slack adapter alike.
5071
6008
  const controlApiChannels = [];
6009
+ /** The API adapter itself, for what the seam does not carry: its address, and the sessions it hands across a restart. */
6010
+ let boundApi;
5072
6011
  if (apiWanted) {
5073
6012
  const createControlApi = await loadControlApi();
5074
6013
  // Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
@@ -5077,16 +6016,23 @@ export const runServe = async (command, streams, env = {}) => {
5077
6016
  // Everything still bound and every test still passed, on whichever port
5078
6017
  // nobody happened to be using.
5079
6018
  const apiHost = command.apiHost ?? apiConfig.host;
5080
- const apiPort = command.apiPort ?? apiConfig.port;
6019
+ const requestedPort = command.apiPort ?? apiConfig.port;
6020
+ // The port the last daemon bound, from the supervisor that started
6021
+ // this one — taken only where this daemon's own request is still for
6022
+ // any free port. See BOUND_API_PORT_ENV.
6023
+ const hinted = Number(readProcessEnv(env)[BOUND_API_PORT_ENV]);
6024
+ const apiPort = requestedPort === 0 && Number.isInteger(hinted) && hinted > 0 ? hinted : requestedPort;
5081
6025
  if (createControlApi) {
5082
- controlApiChannels.push(createControlApi({
6026
+ const api = createControlApi({
5083
6027
  env,
5084
6028
  ...(apiHost !== undefined ? { host: apiHost } : {}),
5085
6029
  ...(apiPort !== undefined ? { port: apiPort } : {}),
5086
6030
  ...(command.configPath ? { configPath: command.configPath } : {}),
5087
6031
  log,
5088
6032
  warn,
5089
- }));
6033
+ });
6034
+ boundApi = api;
6035
+ controlApiChannels.push(api);
5090
6036
  }
5091
6037
  else if (command.api === true || apiConfig.enabled === true) {
5092
6038
  // Only when someone asked for it explicitly. A daemon that was never
@@ -5095,6 +6041,18 @@ export const runServe = async (command, streams, env = {}) => {
5095
6041
  + 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
5096
6042
  }
5097
6043
  }
6044
+ // Listening before anything else is awaited: what the supervisor hands
6045
+ // this daemon is queued in the channel until then, and the API keeps it
6046
+ // until it is serving.
6047
+ const supervised = Boolean(readProcessEnv(env)[SUPERVISED_ENV]);
6048
+ const link = env.supervisorLink ?? defaultSupervisorLink;
6049
+ if (supervised) {
6050
+ link.receive((message) => {
6051
+ if (message.type === 'stratusd.sessions') {
6052
+ boundApi?.adoptSessions(message.sessions);
6053
+ }
6054
+ });
6055
+ }
5098
6056
  const channelCredentials = await loadChannelCredentials(env);
5099
6057
  const slackAgents = Object.entries(channelCredentials.slack ?? {});
5100
6058
  const channels = [...controlApiChannels];
@@ -5192,7 +6150,10 @@ export const runServe = async (command, streams, env = {}) => {
5192
6150
  // a tool that carries no command string is judged by its risk exactly as
5193
6151
  // before. The whitelist lives beside the agent's soul, per agent.
5194
6152
  const commands = {
5195
- whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env) }),
6153
+ // A whitelist that exists and will not read is said here, once, and
6154
+ // never written over — the daemon's log is where a grant list going
6155
+ // quiet would otherwise go unnoticed.
6156
+ whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env), warn }),
5196
6157
  onScopeRemembered: ({ agentId, scope }) => {
5197
6158
  // An approval that widens what runs unattended, for every future
5198
6159
  // session, is precisely the decision that must not be the one leaving
@@ -5200,14 +6161,24 @@ export const runServe = async (command, streams, env = {}) => {
5200
6161
  log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
5201
6162
  },
5202
6163
  };
6164
+ // The origin-scope engine, whose grants live in the same file as the
6165
+ // command scopes: one place an operator looks to see what an agent may
6166
+ // do unattended. Wired unconditionally for the same reason — a tool that
6167
+ // names no origin is judged by its risk exactly as before.
6168
+ const origins = {
6169
+ whitelist: commands.whitelist,
6170
+ onScopeRemembered: ({ agentId, scope }) => {
6171
+ log(`${agentId}: ${describeOriginScope(scope)} is now acted on without asking`);
6172
+ },
6173
+ };
5203
6174
  const approvals = (transport) => createPermissionPolicy(
5204
6175
  // The destination scope rides along in BOTH modes — it is what lets a
5205
6176
  // scheduled turn report to the channel a human approved with the
5206
6177
  // schedule, and headless (where every other gated call is refused) is
5207
6178
  // exactly the deployment it exists for.
5208
6179
  approvalMode === 'remote'
5209
- ? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
5210
- : { mode: 'headless', onDecision, commands, destinations: transport.destinations });
6180
+ ? { mode: 'remote', request: transport.request, onDecision, commands, origins, destinations: transport.destinations }
6181
+ : { mode: 'headless', onDecision, commands, origins, destinations: transport.destinations });
5211
6182
  if (approvalMode === 'remote') {
5212
6183
  // Only agents whose channel actually came up can be asked: tokens on
5213
6184
  // disk with the Slack package missing means nothing renders the
@@ -5215,9 +6186,30 @@ export const runServe = async (command, streams, env = {}) => {
5215
6186
  const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
5216
6187
  log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
5217
6188
  }
6189
+ /**
6190
+ * The one signal handler this daemon has, for the whole of its life —
6191
+ * installed by the wait below, removed in the finally after the drain.
6192
+ * See the wait for why it is never `once` and never removed sooner.
6193
+ */
6194
+ let stopSignal;
6195
+ let repeatWarned = false;
6196
+ // The gateway's restart hands the process back here once it has stopped:
6197
+ // the outcome is kept and the wait below released, and the tail of this
6198
+ // function decides what "come back" means for this process.
6199
+ let restart;
6200
+ /** A signal or the host's abort asked for a stop — set when the wait ends for that reason. */
6201
+ let stopRequested = false;
6202
+ let requestShutdown = () => { };
6203
+ const shutdownRequested = new Promise((resolve) => {
6204
+ requestShutdown = resolve;
6205
+ });
5218
6206
  const gateway = createGateway({
5219
6207
  env,
5220
6208
  approvals,
6209
+ onRestart: (outcome) => {
6210
+ restart = outcome;
6211
+ requestShutdown();
6212
+ },
5221
6213
  ...(Object.keys(pluginsConfig).length > 0
5222
6214
  ? {
5223
6215
  plugins: pluginsConfig,
@@ -5299,36 +6291,46 @@ export const runServe = async (command, streams, env = {}) => {
5299
6291
  });
5300
6292
  }
5301
6293
  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.
6294
+ // Read now, while the API is bound: its stop forgets the address, and
6295
+ // a restart's respawn needs it (see ServeOutcome).
6296
+ const boundApiPort = boundApi?.url !== undefined ? Number(new URL(boundApi.url).port) : undefined;
6297
+ if (supervised && boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0) {
6298
+ link.send({ type: 'stratusd.bound-api-port', port: boundApiPort });
6299
+ }
6300
+ // Under one finally from here: a throw anywhere after the start — a
6301
+ // host's writable refusing a line, say — must still stop the gateway
6302
+ // before the claim on the home is released, or a live gateway would be
6303
+ // left behind a lock the next daemon can take.
5326
6304
  let redirectTimer;
5327
- if (logWriter) {
5328
- redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
5329
- redirectTimer.unref?.();
5330
- }
5331
6305
  try {
6306
+ // The roster is only known once the gateway has loaded it, and in remote
6307
+ // mode an agent no channel can ask for is the quietest failure this
6308
+ // feature has: its gated calls park with nobody rendering them and wait
6309
+ // out the whole timeout before being denied. No channel can detect this
6310
+ // on its own — a request is a broadcast, and no adapter knows whether
6311
+ // another one is about to answer — so it is reported here, where the
6312
+ // roster and the channel list are both in view.
6313
+ if (approvalMode === 'remote') {
6314
+ const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
6315
+ const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
6316
+ if (unreachable.length > 0) {
6317
+ warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
6318
+ + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
6319
+ }
6320
+ }
6321
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
6322
+ // And periodically, for a long-running daemon that warns steadily
6323
+ // without ever writing enough records to rotate. Unref'd, so it never
6324
+ // holds the process open. Armed only once the daemon is actually
6325
+ // serving: every step above can throw, and runServe is an exported
6326
+ // function as much as a process entry point — in a host that survives
6327
+ // the failure, a timer armed before the throw would outlive the call
6328
+ // and go on truncating that environment's redirect logs. The finally
6329
+ // below takes it down on every other exit path.
6330
+ if (logWriter) {
6331
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
6332
+ redirectTimer.unref?.();
6333
+ }
5332
6334
  // Hold the event loop open until a shutdown request, then drain: new
5333
6335
  // dispatches are refused while in-flight turns finish.
5334
6336
  await new Promise((resolve) => {
@@ -5340,12 +6342,46 @@ export const runServe = async (command, streams, env = {}) => {
5340
6342
  }
5341
6343
  settled = true;
5342
6344
  clearInterval(keepAlive);
5343
- process.off('SIGTERM', shutdown);
5344
- process.off('SIGINT', shutdown);
5345
6345
  resolve();
5346
6346
  };
5347
- process.once('SIGTERM', shutdown);
5348
- process.once('SIGINT', shutdown);
6347
+ stopSignal = () => {
6348
+ if (!settled) {
6349
+ shutdown();
6350
+ return;
6351
+ }
6352
+ // A signal while a drain is already running — a repeat, or a stop
6353
+ // arriving during a restart's drain. Either way the drain goes on,
6354
+ // and a daemon told to stop does not come back.
6355
+ stopRequested = true;
6356
+ if (!repeatWarned) {
6357
+ repeatWarned = true;
6358
+ // Said from signal dispatch, outside the promise and its finally:
6359
+ // a stream that throws on write (a host's injected stderr) would
6360
+ // otherwise end the process from here — mid-drain, the very
6361
+ // thing this handler exists to prevent. The word is best effort.
6362
+ try {
6363
+ warn('stop signal received while draining; still draining, and this daemon will not come back (SIGKILL ends it at once)');
6364
+ }
6365
+ catch {
6366
+ // Nothing to say it to; the drain is what matters.
6367
+ }
6368
+ }
6369
+ };
6370
+ // `on`, never `once`, and not removed until the drain is over (the
6371
+ // finally below). `once` drops the listener before running it, Node
6372
+ // uninstalls its native handler the moment no listener is left, and
6373
+ // a second SIGTERM landing in that window ends the process by the
6374
+ // default action — mid-drain, with nothing said. The second signal
6375
+ // is the normal case, not an edge: a stop delivered to the process
6376
+ // group, as systemd's KillMode=control-group and a terminal's Ctrl+C
6377
+ // deliver it, reaches a supervised daemon once from the kernel and
6378
+ // once more forwarded by its supervisor. Reproduced two runs in
6379
+ // three: the replacement died and the supervisor exited 1 on a
6380
+ // `stratus service stop` after a restart. SIGKILL still ends the
6381
+ // process at once.
6382
+ process.on('SIGTERM', stopSignal);
6383
+ process.on('SIGINT', stopSignal);
6384
+ void shutdownRequested.then(shutdown);
5349
6385
  if (env.shutdownSignal?.aborted) {
5350
6386
  shutdown();
5351
6387
  }
@@ -5353,20 +6389,85 @@ export const runServe = async (command, streams, env = {}) => {
5353
6389
  env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
5354
6390
  }
5355
6391
  });
5356
- writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
5357
- await gateway.stop();
5358
- return 0;
6392
+ // Decided now, before the stop below: a restart's drain can still be
6393
+ // running when a stop signal releases this wait, and `onRestart` then
6394
+ // fires during that stop. A daemon told to stop does not come back,
6395
+ // whatever it was told before.
6396
+ if (!restart) {
6397
+ stopRequested = true;
6398
+ }
6399
+ if (restart) {
6400
+ // Already stopped: the gateway drained and closed before handing the
6401
+ // process over, and the stop() below finds nothing to do. Said here,
6402
+ // while the structured log still takes writes — the next lines
6403
+ // belong to a process that has none.
6404
+ if (!restart.drained) {
6405
+ // A turn that ignored its abort is still running in this process,
6406
+ // possibly still writing sessions. Starting a fresh daemon beside
6407
+ // it would be two processes on one store; exiting instead leaves
6408
+ // the service manager, where there is one, to bring it back.
6409
+ warn(`restart: something did not let go; exiting with status ${UNDRAINED_RESTART_EXIT_CODE} for the service manager to restart, instead of starting a new daemon beside it`);
6410
+ }
6411
+ else {
6412
+ log(`restarting stratusd${restart.reason ? ` (${restart.reason})` : ''}`);
6413
+ }
6414
+ }
6415
+ else {
6416
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
6417
+ }
5359
6418
  }
5360
6419
  finally {
5361
- if (redirectTimer) {
5362
- clearInterval(redirectTimer);
6420
+ try {
6421
+ await gateway.stop();
6422
+ // Writes are queued and dropped on the hot path so logging never sits
6423
+ // on a streamed token. That makes the tail of the log the part most
6424
+ // likely to be lost — and the tail is the shutdown reason, the last
6425
+ // warning, the line explaining a restart.
6426
+ await logWriter?.flush();
5363
6427
  }
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
- }
6428
+ finally {
6429
+ // The shutdown is over, the flush included: a stop signal from here
6430
+ // on is the process's to handle by default again — and a host that
6431
+ // calls runServe repeatedly (the tests) must not accumulate handlers.
6432
+ // Last, and under its own finally, because the listener is on the
6433
+ // global process: taken off before the flush, a repeated signal
6434
+ // landing during it would end the process by the default action
6435
+ // (which a supervisor reports as a failure, and a service manager
6436
+ // set to restart on failure would then bring back a daemon an
6437
+ // operator just stopped); left on by a stop() that rejects (its last
6438
+ // log line can throw through an injected stream), a settled handler
6439
+ // would swallow the host's next signal.
6440
+ if (stopSignal) {
6441
+ process.off('SIGTERM', stopSignal);
6442
+ process.off('SIGINT', stopSignal);
6443
+ }
6444
+ if (redirectTimer) {
6445
+ clearInterval(redirectTimer);
6446
+ }
6447
+ }
6448
+ }
6449
+ const bound = boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0
6450
+ ? { boundApiPort }
6451
+ : {};
6452
+ if (!restart || stopRequested) {
6453
+ return { code: 0, ...bound, sessions: [] };
6454
+ }
6455
+ if (!restart.drained) {
6456
+ (env.exitProcess ?? process.exit)(UNDRAINED_RESTART_EXIT_CODE);
6457
+ return { code: UNDRAINED_RESTART_EXIT_CODE, ...bound, sessions: [] };
6458
+ }
6459
+ // Coming back: the browser sessions live at the stop go to the
6460
+ // replacement — up the channel to the supervisor when this daemon has
6461
+ // one, else with the outcome to the supervisor this process becomes.
6462
+ const sessions = boundApi?.sessionsAtStop() ?? [];
6463
+ if (supervised && sessions.length > 0) {
6464
+ link.send({ type: 'stratusd.sessions', sessions });
6465
+ }
6466
+ // Asked to come back. Whether this process starts the next daemon or
6467
+ // exits for its supervisor to is `runServe`'s decision, after the home
6468
+ // is released: the claim is held until this returns, and a daemon
6469
+ // started before that would be refused as a second one on the home.
6470
+ return { code: RESTART_EXIT_CODE, ...bound, sessions };
5370
6471
  };
5371
6472
  export const runCli = async ({ argv, streams = process, env = {} }) => {
5372
6473
  try {
@@ -5405,6 +6506,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
5405
6506
  || command.command === 'run'
5406
6507
  || command.command === 'skill-add'
5407
6508
  || command.command === 'dashboard'
6509
+ || (command.command === 'credential' && command.action !== 'list')
5408
6510
  || (command.command === 'schedules' && command.action === 'cancel')
5409
6511
  || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
5410
6512
  if (writesState) {
@@ -5444,9 +6546,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
5444
6546
  if (command.command === 'skill-add') {
5445
6547
  return await runSkillAdd(command, streams, resolvedEnv);
5446
6548
  }
6549
+ if (command.command === 'skill-validate') {
6550
+ return await runSkillValidate(command, streams, resolvedEnv);
6551
+ }
5447
6552
  if (command.command === 'skills') {
5448
6553
  return await runSkills(streams, resolvedEnv);
5449
6554
  }
6555
+ if (command.command === 'credential') {
6556
+ return await runCredential(command, streams, resolvedEnv);
6557
+ }
6558
+ if (command.command === 'skill-reload') {
6559
+ return await runSkillReload(command, streams, resolvedEnv);
6560
+ }
6561
+ if (command.command === 'restart') {
6562
+ return await runRestart(command, streams, resolvedEnv);
6563
+ }
5450
6564
  if (command.command === 'schedules') {
5451
6565
  return await runSchedules(command, streams, resolvedEnv);
5452
6566
  }