@stratusagent/cli 0.11.1 → 0.11.2

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
@@ -13,14 +13,14 @@ import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { createInterface } from 'node:readline';
16
- import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, originOf, } from '@stratusagent/core';
16
+ import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, isTrustLevel, matchesSkillAllowlist, escapeControlCharacters, memoryEntryTrust, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, originOf, TRUST_LEVELS, } from '@stratusagent/core';
17
17
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
18
18
  import { loadPlugins } from '@stratusagent/plugins';
19
19
  import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, describeOriginScope, } from '@stratusagent/permissions';
20
20
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
21
21
  import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
22
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
+ 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, resolveAgentPrincipals, 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';
24
24
  import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
25
25
  export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
26
26
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -133,7 +133,7 @@ const defaultPackageInstaller = async (packages) => {
133
133
  * something other than this command.
134
134
  */
135
135
  export const CREDENTIAL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
136
- export const CLI_VERSION = '0.11.1';
136
+ export const CLI_VERSION = '0.11.2';
137
137
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
138
138
  /**
139
139
  * The range every package declares in its `engines` field. Two floors, not
@@ -259,6 +259,9 @@ Usage:
259
259
  stratus restart
260
260
  stratus schedules
261
261
  stratus schedules cancel <id>
262
+ stratus memory list ava
263
+ stratus memory reassert ava --trust user --all-unknown
264
+ stratus session rollover slack:ava:T01ABCDEF:D07GHIJKL
262
265
  printf %s "$BRAVE_KEY" | stratus credential set search.apiKey
263
266
  stratus credentials
264
267
  stratus doctor
@@ -347,6 +350,24 @@ Commands:
347
350
  cancel <id>" stops the next firing and revokes the
348
351
  destination that was approved with it
349
352
  (also: stratus schedule list / schedule cancel <id>)
353
+ memory list Show an agent's live memory with the trust label each
354
+ entry carries — user, agent, unknown (no recorded origin,
355
+ or written in a conversation with someone not configured
356
+ as a principal), external (recorded after reading content
357
+ from outside). --trust <level> filters; --format json
358
+ memory reassert Re-label entries as an operator: stratus memory reassert
359
+ <agent> --trust user <id>..., or --all-unknown for every
360
+ entry with no recorded origin (the upgrade case; an
361
+ entry recorded unknown — a stranger's message, a shell
362
+ command — is left for you to name by id). Appends
363
+ a record to ~/.stratus/memory.jsonl; a running daemon
364
+ sees it on its next turn. The only way a label ever
365
+ rises — no tool can do this
366
+ session rollover Start a conversation over under the same session id,
367
+ archiving its transcript so far — for a long-lived
368
+ session (a Slack DM) that predates provenance tracking
369
+ and would otherwise write unknown forever. Asks the
370
+ running daemon (--gateway, --token)
350
371
  agent new Create an agent identity (generates a human-ish name + avatar theme)
351
372
  agents List your agents: who they are, where their souls live, what
352
373
  they run on, what they remember (also: stratus agent list).
@@ -392,9 +413,12 @@ Options:
392
413
  --port dashboard: port for a daemon it starts (default: 4123)
393
414
  --host dashboard: host for a daemon it starts (default: 127.0.0.1)
394
415
  --no-open Do not open the browser automatically
395
- --gateway agents / skill reload / restart: a running daemon's control
396
- API (skill reload and restart default to the daemon
416
+ --gateway agents / skill reload / restart / session rollover: a running
417
+ daemon's control API (all but agents default to the daemon
397
418
  ~/.stratus/gateway.json names)
419
+ --trust memory list: show only this label; memory reassert: the
420
+ label to record (user, agent, unknown, external)
421
+ --all-unknown memory reassert: every live entry with no recorded origin
398
422
  --token Bearer token for --gateway (default: ~/.stratus/gateway-token)
399
423
  --no-reload skill add: install without reloading a running daemon
400
424
  --reason restart: why, for the daemon's log
@@ -1046,6 +1070,123 @@ export const parseCommand = (argv, env = {}) => {
1046
1070
  ...(reload ? {} : { reload }),
1047
1071
  };
1048
1072
  }
1073
+ if (command === 'memory') {
1074
+ const [action, ...memoryRest] = rest;
1075
+ if (action === undefined || action === '--help' || action === '-h') {
1076
+ return { command: 'help' };
1077
+ }
1078
+ if (action !== 'list' && action !== 'reassert') {
1079
+ throw new Error(`Unknown memory subcommand: ${action}. Try: stratus memory list <agent>, stratus memory reassert <agent> --trust user <id>...`);
1080
+ }
1081
+ let agentId;
1082
+ let trust;
1083
+ let format = 'text';
1084
+ let allUnknown = false;
1085
+ const ids = [];
1086
+ for (let index = 0; index < memoryRest.length; index += 1) {
1087
+ const token = memoryRest[index];
1088
+ if (!token) {
1089
+ continue;
1090
+ }
1091
+ if (token === '--help' || token === '-h') {
1092
+ return { command: 'help' };
1093
+ }
1094
+ if (token === '--trust') {
1095
+ const value = readOptionValue(memoryRest, index, '--trust');
1096
+ if (!isTrustLevel(value)) {
1097
+ throw new Error(`Unsupported trust level: ${value}. Use one of ${TRUST_LEVELS.join(', ')}.`);
1098
+ }
1099
+ trust = value;
1100
+ index += 1;
1101
+ continue;
1102
+ }
1103
+ if (token === '--format') {
1104
+ const value = readOptionValue(memoryRest, index, '--format');
1105
+ if (value !== 'text' && value !== 'json') {
1106
+ throw new Error(`Unsupported format: ${value}`);
1107
+ }
1108
+ format = value;
1109
+ index += 1;
1110
+ continue;
1111
+ }
1112
+ if (token === '--all-unknown' && action === 'reassert') {
1113
+ allUnknown = true;
1114
+ continue;
1115
+ }
1116
+ if (token.startsWith('--')) {
1117
+ throw new Error(`Unknown option: ${token}`);
1118
+ }
1119
+ if (agentId === undefined) {
1120
+ agentId = token;
1121
+ continue;
1122
+ }
1123
+ if (action === 'reassert') {
1124
+ ids.push(token);
1125
+ continue;
1126
+ }
1127
+ throw new Error(`Unexpected argument: ${token}. Try: stratus memory list <agent>`);
1128
+ }
1129
+ if (!agentId) {
1130
+ throw new Error(`memory ${action} needs the agent id: stratus memory ${action} <agent>${action === 'reassert' ? ' --trust user <id>...' : ''}.`);
1131
+ }
1132
+ if (action === 'reassert') {
1133
+ if (trust === undefined) {
1134
+ throw new Error('memory reassert needs --trust <user|agent|external|unknown>: the label you are vouching for.');
1135
+ }
1136
+ if (ids.length === 0 && !allUnknown) {
1137
+ throw new Error('memory reassert needs entry ids, or --all-unknown to re-label every entry that has no recorded origin.');
1138
+ }
1139
+ }
1140
+ return {
1141
+ command: 'memory',
1142
+ action,
1143
+ agentId,
1144
+ ...(trust !== undefined ? { trust } : {}),
1145
+ ids,
1146
+ allUnknown,
1147
+ format,
1148
+ };
1149
+ }
1150
+ if (command === 'session') {
1151
+ const [action, ...sessionRest] = rest;
1152
+ if (action === undefined || action === '--help' || action === '-h') {
1153
+ return { command: 'help' };
1154
+ }
1155
+ if (action !== 'rollover') {
1156
+ throw new Error(`Unknown session subcommand: ${action}. Try: stratus session rollover <session-id>`);
1157
+ }
1158
+ const parsed = { command: 'session', action: 'rollover', sessionId: '' };
1159
+ for (let index = 0; index < sessionRest.length; index += 1) {
1160
+ const token = sessionRest[index];
1161
+ if (!token) {
1162
+ continue;
1163
+ }
1164
+ if (token === '--help' || token === '-h') {
1165
+ return { command: 'help' };
1166
+ }
1167
+ if (token === '--gateway') {
1168
+ parsed.gateway = readOptionValue(sessionRest, index, '--gateway');
1169
+ index += 1;
1170
+ continue;
1171
+ }
1172
+ if (token === '--token') {
1173
+ parsed.token = readOptionValue(sessionRest, index, '--token');
1174
+ index += 1;
1175
+ continue;
1176
+ }
1177
+ if (token.startsWith('--')) {
1178
+ throw new Error(`Unknown option: ${token}`);
1179
+ }
1180
+ if (parsed.sessionId.length > 0) {
1181
+ throw new Error(`Unexpected argument: ${token}. Try: stratus session rollover <session-id>`);
1182
+ }
1183
+ parsed.sessionId = token;
1184
+ }
1185
+ if (parsed.sessionId.length === 0) {
1186
+ throw new Error('session rollover needs the session id: stratus session rollover <session-id>. `stratus logs` shows ids in its last column.');
1187
+ }
1188
+ return parsed;
1189
+ }
1049
1190
  if (command === 'restart') {
1050
1191
  const parsed = { command: 'restart' };
1051
1192
  for (let index = 0; index < rest.length; index += 1) {
@@ -1291,6 +1432,8 @@ export const formatEvent = (event) => {
1291
1432
  return `• session.completed ${event.sessionId}`;
1292
1433
  case 'session.failed':
1293
1434
  return `• session.failed ${event.error}`;
1435
+ case 'session.tainted':
1436
+ return `• session.tainted ${event.trust} (${event.source})`;
1294
1437
  default:
1295
1438
  return null;
1296
1439
  }
@@ -1341,6 +1484,11 @@ export const eventDetail = (event) => {
1341
1484
  };
1342
1485
  case 'session.failed':
1343
1486
  return { error: event.error };
1487
+ case 'session.tainted':
1488
+ // The label and what lowered it — a tool's name, or `memory`,
1489
+ // `sender`, `legacy`. Never the content that did: same rule as every
1490
+ // other event here.
1491
+ return { trust: event.trust, source: event.source };
1344
1492
  default:
1345
1493
  return undefined;
1346
1494
  }
@@ -4301,6 +4449,37 @@ const loadServeApprovals = async (env, configPath, warn) => {
4301
4449
  * `agentIds` is therefore the agents that can actually be *asked*, not
4302
4450
  * every agent with tokens on disk.
4303
4451
  */
4452
+ /**
4453
+ * The `principals` block, read under the same trust rule as `approvals` and
4454
+ * for a sharper reason: it names the people whose messages an agent takes
4455
+ * as its operator's, which is the trust root everything downstream lowers
4456
+ * from. A cloned repo appointing itself the principal would arrive as
4457
+ * `user` in every Slack thread the daemon serves.
4458
+ */
4459
+ const loadServePrincipals = async (env, configPath, warn) => {
4460
+ const block = await readTrustedConfigBlock('principals', env, configPath);
4461
+ if (block.status === 'untrusted') {
4462
+ warn(`ignoring the principals config in ${block.path}: a project-local config cannot decide whose messages `
4463
+ + 'this daemon\'s agents treat as their operator\'s. Move it to ~/.stratus/config.json, or pass it with --config.');
4464
+ return {};
4465
+ }
4466
+ if (block.status === 'unreadable') {
4467
+ warn(`ignoring the principals config (${block.error instanceof Error ? block.error.message : String(block.error)}); every Slack sender is unknown`);
4468
+ return {};
4469
+ }
4470
+ return block.status === 'present' ? block.value : {};
4471
+ };
4472
+ /** One line saying whose messages arrive as the operator's, per Slack agent. */
4473
+ const describePrincipals = (principals, agentIds) => {
4474
+ const covered = agentIds.filter((agentId) => (resolveAgentPrincipals(principals, agentId).slackUsers ?? []).length > 0);
4475
+ if (covered.length === 0) {
4476
+ return 'no principals configured, so every Slack sender is unknown and every fact written in Slack carries that label — set principals.slackUsers in ~/.stratus/config.json';
4477
+ }
4478
+ const uncovered = agentIds.filter((agentId) => !covered.includes(agentId));
4479
+ return uncovered.length === 0
4480
+ ? `principals set for ${covered.join(', ')}`
4481
+ : `principals set for ${covered.join(', ')}; none for ${uncovered.join(', ')}, whose Slack senders are all unknown`;
4482
+ };
4304
4483
  const describeApprovers = (approvals, agentIds) => {
4305
4484
  if (agentIds.length === 0) {
4306
4485
  return 'but no channel is running to ask through, so gated calls will wait out the approval timeout and then be denied';
@@ -5142,6 +5321,134 @@ export const runSkillReload = async (command, streams, env = {}) => {
5142
5321
  writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
5143
5322
  return 0;
5144
5323
  };
5324
+ /**
5325
+ * `stratus memory list|reassert` — the operator's half of provenance.
5326
+ *
5327
+ * `list` shows what an agent's store holds with the label each entry
5328
+ * carries, because an operator cannot re-assert what they cannot see.
5329
+ * `reassert` is the one way a label ever rises: it appends a re-assertion
5330
+ * record to the JSONL (never rewrites a line), so a running daemon — which
5331
+ * re-reads the file on every read — sees it at once. Operator only, on
5332
+ * purpose: no tool exposes this, because an agent re-labelling its own
5333
+ * memory as trusted is the attack writing its own permission slip.
5334
+ *
5335
+ * `--all-unknown` exists for the upgrade: every entry written before labels
5336
+ * existed reads `unknown`, and the injected slice of such a store makes
5337
+ * every session `unknown` on its first turn. Re-asserting the entries the
5338
+ * agent actually surfaces, once, is the bounded work that drains it.
5339
+ */
5340
+ export const runMemory = async (command, streams, env = {}) => {
5341
+ // Only the writer folds a legacy per-directory store in: `list` is the
5342
+ // read-only command a downgraded build is allowed to run against newer
5343
+ // state, and a migration is a write.
5344
+ if (command.action === 'reassert') {
5345
+ await migrateLegacyMemory(env);
5346
+ }
5347
+ const store = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
5348
+ // Two ways an entry reads `unknown`, and only one is the upgrade case:
5349
+ // no label at all (written before labels existed, or added by hand), or a
5350
+ // recorded `unknown` — a session that heard from a sender nobody vouched
5351
+ // for, or ran a shell command. The bulk re-assertion is for the first;
5352
+ // the second may hold a stranger's text and is named by id, once read.
5353
+ const unlabelled = (entry) => entry.trust === undefined;
5354
+ const describeEntry = (entry) => ({
5355
+ id: entry.id,
5356
+ trust: memoryEntryTrust(entry),
5357
+ content: entry.content,
5358
+ createdAt: entry.createdAt,
5359
+ ...(entry.origin ? { origin: entry.origin } : {}),
5360
+ });
5361
+ if (command.action === 'list') {
5362
+ const live = (await store.list(command.agentId)).entries
5363
+ .filter((entry) => command.trust === undefined || memoryEntryTrust(entry) === command.trust);
5364
+ if (command.format === 'json') {
5365
+ writeLine(streams.stdout, JSON.stringify({ agentId: command.agentId, entries: live.map(describeEntry) }, null, 2));
5366
+ return 0;
5367
+ }
5368
+ if (live.length === 0) {
5369
+ writeLine(streams.stdout, command.trust === undefined
5370
+ ? `${command.agentId} has no live memory entries in ${memoryFilePath(env)}.`
5371
+ : `${command.agentId} has no live memory entries at ${command.trust}.`);
5372
+ return 0;
5373
+ }
5374
+ for (const entry of live) {
5375
+ const taintedBy = entry.origin?.taintedBy ? ` (tainted by ${entry.origin.taintedBy})` : '';
5376
+ const unrecorded = unlabelled(entry) ? ' (no recorded origin)' : '';
5377
+ writeLine(streams.stdout, `${entry.id} [${memoryEntryTrust(entry)}]${taintedBy}${unrecorded}`);
5378
+ writeLine(streams.stdout, ` ${escapeControlCharacters(entry.content)}`);
5379
+ }
5380
+ if (command.trust === undefined) {
5381
+ const unlabelledCount = live.filter(unlabelled).length;
5382
+ const recordedUnknown = live.filter((entry) => !unlabelled(entry) && memoryEntryTrust(entry) === 'unknown').length;
5383
+ if (unlabelledCount > 0) {
5384
+ writeLine(streams.stdout, '');
5385
+ writeLine(streams.stdout, `${unlabelledCount} entr${unlabelledCount === 1 ? 'y has' : 'ies have'} no recorded origin, so any session that reads one writes unknown. `
5386
+ + `Review them, then: stratus memory reassert ${command.agentId} --trust user --all-unknown (or name ids).`);
5387
+ }
5388
+ if (recordedUnknown > 0) {
5389
+ writeLine(streams.stdout, '');
5390
+ writeLine(streams.stdout, `${recordedUnknown} entr${recordedUnknown === 1 ? 'y was' : 'ies were'} recorded unknown — written after a message from someone not configured as a principal, or after a shell command — `
5391
+ + 'and may repeat what a stranger said. --all-unknown leaves these alone; re-assert one by id once you have read it.');
5392
+ }
5393
+ }
5394
+ return 0;
5395
+ }
5396
+ if (!store.reassertTrust) {
5397
+ writeLine(streams.stderr, 'Error: this memory store cannot re-assert trust.');
5398
+ return 1;
5399
+ }
5400
+ const trust = command.trust ?? 'user';
5401
+ const live = (await store.list(command.agentId)).entries;
5402
+ const targets = command.allUnknown
5403
+ ? [...new Set([...live.filter(unlabelled).map((entry) => entry.id), ...command.ids])]
5404
+ : command.ids;
5405
+ if (targets.length === 0) {
5406
+ writeLine(streams.stdout, `${command.agentId} has no live entries with no recorded origin; nothing to re-assert. An entry recorded unknown is named by id.`);
5407
+ return 0;
5408
+ }
5409
+ const missing = [];
5410
+ let changed = 0;
5411
+ for (const id of targets) {
5412
+ if (await store.reassertTrust(command.agentId, id, trust)) {
5413
+ changed += 1;
5414
+ }
5415
+ else {
5416
+ missing.push(id);
5417
+ }
5418
+ }
5419
+ if (command.format === 'json') {
5420
+ writeLine(streams.stdout, JSON.stringify({ agentId: command.agentId, trust, reasserted: changed, missing }, null, 2));
5421
+ }
5422
+ else {
5423
+ writeLine(streams.stdout, `Re-asserted ${changed} entr${changed === 1 ? 'y' : 'ies'} of ${command.agentId} as ${trust}. A running daemon reads the change on its next turn.`);
5424
+ }
5425
+ for (const id of missing) {
5426
+ writeLine(streams.stderr, `No live memory entry with id ${id} belongs to ${command.agentId}; nothing was re-asserted for it.`);
5427
+ }
5428
+ return missing.length === 0 ? 0 : 1;
5429
+ };
5430
+ /**
5431
+ * `stratus session rollover <id>` — start a conversation over under the
5432
+ * same id, archiving its transcript so far. The daemon does the work, on
5433
+ * the session's own chain, so a turn cannot interleave; this only asks.
5434
+ */
5435
+ export const runSessionRollover = async (command, streams, env = {}) => {
5436
+ const base = await runningGatewayBase(env, command);
5437
+ if (!base) {
5438
+ writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
5439
+ return 1;
5440
+ }
5441
+ const response = await callRunningGateway(env, command, base, `/api/v1/sessions/${encodeURIComponent(command.sessionId)}/rollover`);
5442
+ if (!response.ok) {
5443
+ writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
5444
+ return 1;
5445
+ }
5446
+ const outcome = await response.json();
5447
+ writeLine(streams.stdout, `Rolled over ${outcome.sessionId ?? command.sessionId}: the next message starts a fresh conversation, `
5448
+ + `and the transcript so far is archived as ${outcome.archivedAs ?? '(unknown)'}.`);
5449
+ writeLine(streams.stdout, 'A fresh session still reads the memory it injects — if that slice holds entries with no recorded origin, `stratus memory list <agent>` shows them and `stratus memory reassert` re-labels them.');
5450
+ return 0;
5451
+ };
5145
5452
  export const runRestart = async (command, streams, env = {}) => {
5146
5453
  const base = await runningGatewayBase(env, command);
5147
5454
  if (!base) {
@@ -6022,6 +6329,7 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
6022
6329
  // approve this" differently from "is anyone being asked at all".
6023
6330
  const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
6024
6331
  const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
6332
+ const principalsConfig = await loadServePrincipals(env, command.configPath, warn);
6025
6333
  // Read here rather than inside the gateway for the same reason as the two
6026
6334
  // blocks above: the trust boundary is a property of *which file* said it,
6027
6335
  // and this is where the file precedence is already understood.
@@ -6102,12 +6410,14 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
6102
6410
  channels.push(adapter({
6103
6411
  agents: slackAgents.map(([agentId, tokens]) => {
6104
6412
  const route = resolveAgentApprovals(approvalsConfig, agentId);
6413
+ const principals = resolveAgentPrincipals(principalsConfig, agentId);
6105
6414
  return {
6106
6415
  agentId,
6107
6416
  appToken: tokens.appToken,
6108
6417
  botToken: tokens.botToken,
6109
6418
  ...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
6110
6419
  ...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
6420
+ ...(principals.slackUsers ? { principals: principals.slackUsers } : {}),
6111
6421
  };
6112
6422
  }),
6113
6423
  log,
@@ -6214,6 +6524,12 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
6214
6524
  const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
6215
6525
  log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
6216
6526
  }
6527
+ if (slackAdapterUp) {
6528
+ // Said at startup, because the failure it names is silent otherwise: a
6529
+ // daemon with no principals serves every Slack message as `unknown`,
6530
+ // and the operator finds out when their agent's memory reads that way.
6531
+ log(`provenance: ${describePrincipals(principalsConfig, slackAgents.map(([agentId]) => agentId))}`);
6532
+ }
6217
6533
  /**
6218
6534
  * The one signal handler this daemon has, for the whole of its life —
6219
6535
  * installed by the wait below, removed in the finally after the drain.
@@ -6519,6 +6835,17 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6519
6835
  // to be able to report what is pending rather than having just done it.
6520
6836
  if (command.command !== 'update') {
6521
6837
  const stamp = await readStateStamp(resolvedEnv);
6838
+ const writesState = command.command === 'serve'
6839
+ || command.command === 'setup'
6840
+ || command.command === 'chat'
6841
+ || command.command === 'run'
6842
+ || command.command === 'skill-add'
6843
+ || command.command === 'dashboard'
6844
+ || (command.command === 'credential' && command.action !== 'list')
6845
+ || (command.command === 'schedules' && command.action === 'cancel')
6846
+ || (command.command === 'memory' && command.action === 'reassert')
6847
+ || command.command === 'session'
6848
+ || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
6522
6849
  if (stamp.schemaVersion > STATE_SCHEMA_VERSION) {
6523
6850
  // Anything that writes under ~/.stratus refuses, not only the
6524
6851
  // daemon: a downgraded build's setup, chat, or run can discard
@@ -6528,15 +6855,6 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6528
6855
  // diagnoses their way OUT of this state; so do `service stop`,
6529
6856
  // `status`, and `uninstall`, for the same reason. (`agent new`
6530
6857
  // only prints an identity — it writes nothing.)
6531
- const writesState = command.command === 'serve'
6532
- || command.command === 'setup'
6533
- || command.command === 'chat'
6534
- || command.command === 'run'
6535
- || command.command === 'skill-add'
6536
- || command.command === 'dashboard'
6537
- || (command.command === 'credential' && command.action !== 'list')
6538
- || (command.command === 'schedules' && command.action === 'cancel')
6539
- || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
6540
6858
  if (writesState) {
6541
6859
  writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
6542
6860
  writeLine(streams.stderr, `Refusing \`stratus ${command.command}\` — it writes state the newer format owns. Read-only commands (logs, agents, doctor, service status/stop) still work.`);
@@ -6553,10 +6871,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6553
6871
  }
6554
6872
  }
6555
6873
  catch (error) {
6556
- // A failed migration must not brick every command, but running
6557
- // on unmigrated state is worth a line: silence here is how the
6558
- // migrated and unmigrated populations diverge.
6559
- writeLine(streams.stderr, `Warning: state migration failed (${error instanceof Error ? error.message : String(error)}). Continuing on unmigrated state — \`stratus update\` retries it.`);
6874
+ const reason = error instanceof Error ? error.message : String(error);
6875
+ // A command that writes state must not run on a home whose stamp
6876
+ // could not be written: the labels it would persist are what the
6877
+ // stamp protects, and a stamp still reading the old schema lets a
6878
+ // downgraded build in afterwards to read an `external` fact as
6879
+ // ordinary memory — the exact hole the stamp closes. A read-only
6880
+ // command warns and continues, because reading is how someone
6881
+ // diagnoses their way out; silence here is how the migrated and
6882
+ // unmigrated populations diverge.
6883
+ if (writesState) {
6884
+ writeLine(streams.stderr, `State migration failed (${reason}).`);
6885
+ writeLine(streams.stderr, `Refusing \`stratus ${command.command}\` — it writes state the migration stamps. Fix ~/.stratus (is state.json writable?) or run \`stratus update\`; read-only commands (logs, agents, doctor, memory list, service status/stop) still work.`);
6886
+ return 1;
6887
+ }
6888
+ writeLine(streams.stderr, `Warning: state migration failed (${reason}). Continuing on unmigrated state — \`stratus update\` retries it.`);
6560
6889
  }
6561
6890
  }
6562
6891
  }
@@ -6592,6 +6921,12 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6592
6921
  if (command.command === 'schedules') {
6593
6922
  return await runSchedules(command, streams, resolvedEnv);
6594
6923
  }
6924
+ if (command.command === 'memory') {
6925
+ return await runMemory(command, streams, resolvedEnv);
6926
+ }
6927
+ if (command.command === 'session') {
6928
+ return await runSessionRollover(command, streams, resolvedEnv);
6929
+ }
6595
6930
  if (command.command === 'doctor') {
6596
6931
  return await runDoctor(command, streams, resolvedEnv);
6597
6932
  }