@stratusagent/cli 0.11.0 → 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, 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
- 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';
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, 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.0';
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
  }
@@ -1597,6 +1745,26 @@ const createAgentRuntime = async (streams, options) => {
1597
1745
  for (const { skill, missing } of missingSkillRequirements(agent, skills)) {
1598
1746
  writeLine(streams.stderr, `Warning: agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
1599
1747
  }
1748
+ // And the same for the tools themselves, after `initialize()` so the
1749
+ // plugins have registered theirs: an allowlist entry naming a tool
1750
+ // nothing provides grants nothing and says nothing, and a soul made only
1751
+ // of those runs with no tools while its persona still talks about them.
1752
+ const finding = unmatchedToolAllowlist(agent, tools.describe().map((tool) => tool.name), {
1753
+ mayRegister: loadedPlugins.flatMap((plugin) => plugin.manifest.contributes.toolsDiscovered.map((declared) => declared.namespace)),
1754
+ // A local run has no dispatcher, store, or channels, so it registers
1755
+ // none of the daemon's tools. A soul written for `stratus serve` is
1756
+ // not wrong for naming them here — it is in the wrong process.
1757
+ elsewhere: GATEWAY_ONLY_TOOL_NAMES,
1758
+ });
1759
+ for (const line of finding ? describeToolAllowlistFinding(agent.id, finding) : []) {
1760
+ writeLine(streams.stderr, `Warning: ${line}`);
1761
+ }
1762
+ // The kernel leaves this one to the host, because only the host knows
1763
+ // which process it is not.
1764
+ if (finding && finding.elsewhere.length > 0) {
1765
+ writeLine(streams.stderr, `Warning: agent ${agent.id} lists ${finding.elsewhere.join(', ')}, which only the daemon provides`
1766
+ + ' — the names are right, but stratus run cannot call them; stratus serve can');
1767
+ }
1600
1768
  const metadata = options.runtime.provider === 'demo'
1601
1769
  ? { provider: 'demo', executor: 'local-command' }
1602
1770
  : {
@@ -2259,20 +2427,28 @@ const verifySlackAppToken = async (token, fetchImpl) => {
2259
2427
  // package being installed.
2260
2428
  const SLACK_BOT_SCOPES = [
2261
2429
  'app_mentions:read',
2430
+ // The history family is what lets an agent stay in a conversation it was
2431
+ // brought into: without it Slack delivers only mentions, and a reply in
2432
+ // the agent's own thread never reaches it. It is the workspace's switch
2433
+ // for that — an app installed without these scopes answers mentions and
2434
+ // nothing else, exactly as it always did.
2435
+ 'channels:history',
2262
2436
  // The conversations read family backs outbound destination validation
2263
2437
  // (conversations.info): whether a channel a schedule wants to report to
2264
2438
  // exists, and whether this app is a member of it.
2265
2439
  'channels:read',
2266
2440
  'chat:write',
2267
2441
  'files:write',
2442
+ 'groups:history',
2268
2443
  'groups:read',
2269
2444
  'im:history',
2270
2445
  'im:read',
2271
2446
  'im:write',
2447
+ 'mpim:history',
2272
2448
  'mpim:read',
2273
2449
  'users:read',
2274
2450
  ];
2275
- const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
2451
+ const SLACK_BOT_EVENTS = ['app_mention', 'message.channels', 'message.groups', 'message.im', 'message.mpim'];
2276
2452
  export const slackAppManifest = (agentName) => JSON.stringify({
2277
2453
  display_information: {
2278
2454
  name: agentName,
@@ -4273,6 +4449,37 @@ const loadServeApprovals = async (env, configPath, warn) => {
4273
4449
  * `agentIds` is therefore the agents that can actually be *asked*, not
4274
4450
  * every agent with tokens on disk.
4275
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
+ };
4276
4483
  const describeApprovers = (approvals, agentIds) => {
4277
4484
  if (agentIds.length === 0) {
4278
4485
  return 'but no channel is running to ask through, so gated calls will wait out the approval timeout and then be denied';
@@ -5114,6 +5321,134 @@ export const runSkillReload = async (command, streams, env = {}) => {
5114
5321
  writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
5115
5322
  return 0;
5116
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
+ };
5117
5452
  export const runRestart = async (command, streams, env = {}) => {
5118
5453
  const base = await runningGatewayBase(env, command);
5119
5454
  if (!base) {
@@ -5994,6 +6329,7 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
5994
6329
  // approve this" differently from "is anyone being asked at all".
5995
6330
  const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
5996
6331
  const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
6332
+ const principalsConfig = await loadServePrincipals(env, command.configPath, warn);
5997
6333
  // Read here rather than inside the gateway for the same reason as the two
5998
6334
  // blocks above: the trust boundary is a property of *which file* said it,
5999
6335
  // and this is where the file precedence is already understood.
@@ -6074,12 +6410,14 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
6074
6410
  channels.push(adapter({
6075
6411
  agents: slackAgents.map(([agentId, tokens]) => {
6076
6412
  const route = resolveAgentApprovals(approvalsConfig, agentId);
6413
+ const principals = resolveAgentPrincipals(principalsConfig, agentId);
6077
6414
  return {
6078
6415
  agentId,
6079
6416
  appToken: tokens.appToken,
6080
6417
  botToken: tokens.botToken,
6081
6418
  ...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
6082
6419
  ...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
6420
+ ...(principals.slackUsers ? { principals: principals.slackUsers } : {}),
6083
6421
  };
6084
6422
  }),
6085
6423
  log,
@@ -6186,6 +6524,12 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
6186
6524
  const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
6187
6525
  log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
6188
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
+ }
6189
6533
  /**
6190
6534
  * The one signal handler this daemon has, for the whole of its life —
6191
6535
  * installed by the wait below, removed in the finally after the drain.
@@ -6491,6 +6835,17 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6491
6835
  // to be able to report what is pending rather than having just done it.
6492
6836
  if (command.command !== 'update') {
6493
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'));
6494
6849
  if (stamp.schemaVersion > STATE_SCHEMA_VERSION) {
6495
6850
  // Anything that writes under ~/.stratus refuses, not only the
6496
6851
  // daemon: a downgraded build's setup, chat, or run can discard
@@ -6500,15 +6855,6 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6500
6855
  // diagnoses their way OUT of this state; so do `service stop`,
6501
6856
  // `status`, and `uninstall`, for the same reason. (`agent new`
6502
6857
  // only prints an identity — it writes nothing.)
6503
- const writesState = command.command === 'serve'
6504
- || command.command === 'setup'
6505
- || command.command === 'chat'
6506
- || command.command === 'run'
6507
- || command.command === 'skill-add'
6508
- || command.command === 'dashboard'
6509
- || (command.command === 'credential' && command.action !== 'list')
6510
- || (command.command === 'schedules' && command.action === 'cancel')
6511
- || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
6512
6858
  if (writesState) {
6513
6859
  writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
6514
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.`);
@@ -6525,10 +6871,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6525
6871
  }
6526
6872
  }
6527
6873
  catch (error) {
6528
- // A failed migration must not brick every command, but running
6529
- // on unmigrated state is worth a line: silence here is how the
6530
- // migrated and unmigrated populations diverge.
6531
- 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.`);
6532
6889
  }
6533
6890
  }
6534
6891
  }
@@ -6564,6 +6921,12 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
6564
6921
  if (command.command === 'schedules') {
6565
6922
  return await runSchedules(command, streams, resolvedEnv);
6566
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
+ }
6567
6930
  if (command.command === 'doctor') {
6568
6931
  return await runDoctor(command, streams, resolvedEnv);
6569
6932
  }