@stratusagent/cli 0.9.0 → 0.9.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
@@ -17,16 +17,69 @@ import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
17
17
  import { loadPlugins } from '@stratusagent/plugins';
18
18
  import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
19
19
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
20
+ import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
20
21
  import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
21
- 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, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
22
- import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
23
- export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
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';
23
+ import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
24
+ export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
24
25
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
25
26
  export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, parseLogLines, readRecentRecords, tailLog, } from "./logs.js";
26
27
  // The shared state package owns config resolution, credentials, memory, and
27
28
  // provider wiring now (the gateway uses the same code); the CLI re-exports
28
29
  // its historical surface so existing importers keep working.
29
30
  export { createFileMemoryStore } from '@stratusagent/state';
31
+ /** How long `stratus update` waits for npm to answer a version lookup. */
32
+ export const VERSION_LOOKUP_TIMEOUT_MS = 15_000;
33
+ const defaultPackageVersionFetcher = async (packageName) => {
34
+ const { spawn } = await import('node:child_process');
35
+ return new Promise((resolve) => {
36
+ const child = spawn('npm', ['view', packageName, 'version'], {
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ shell: npmNeedsShell(process.platform),
39
+ });
40
+ let stdout = '';
41
+ let settled = false;
42
+ const finish = (value) => {
43
+ if (settled) {
44
+ return;
45
+ }
46
+ settled = true;
47
+ clearTimeout(timer);
48
+ resolve(value);
49
+ };
50
+ // An unreachable registry must degrade to "unknown", never hang the
51
+ // update on someone's terminal.
52
+ const timer = setTimeout(() => {
53
+ child.kill('SIGKILL');
54
+ finish(undefined);
55
+ }, VERSION_LOOKUP_TIMEOUT_MS);
56
+ timer.unref?.();
57
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
58
+ child.once('error', () => finish(undefined));
59
+ child.once('close', (code) => finish(code === 0 && stdout.trim().length > 0 ? stdout.trim() : undefined));
60
+ });
61
+ };
62
+ /**
63
+ * Dotted-numeric comparison, enough for this package's own versions:
64
+ * positive when `a` is newer than `b`. Anything unparseable in a segment
65
+ * counts as zero rather than throwing — a weird registry answer must not
66
+ * crash the update that would fix things.
67
+ */
68
+ export const compareVersions = (a, b) => {
69
+ const parse = (value) => value.trim().replace(/^v/, '').split('.').map((part) => {
70
+ const numeric = Number.parseInt(part, 10);
71
+ return Number.isInteger(numeric) ? numeric : 0;
72
+ });
73
+ const left = parse(a);
74
+ const right = parse(b);
75
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
76
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
77
+ if (difference !== 0) {
78
+ return difference;
79
+ }
80
+ }
81
+ return 0;
82
+ };
30
83
  /**
31
84
  * Whether spawning npm needs a shell on this platform.
32
85
  *
@@ -70,7 +123,7 @@ const defaultPackageInstaller = async (packages) => {
70
123
  });
71
124
  });
72
125
  };
73
- export const CLI_VERSION = '0.9.0';
126
+ export const CLI_VERSION = '0.9.2';
74
127
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
75
128
  /**
76
129
  * The range every package declares in its `engines` field. Two floors, not
@@ -130,6 +183,8 @@ Usage:
130
183
  stratus schedules
131
184
  stratus schedules cancel <id>
132
185
  stratus doctor
186
+ stratus update
187
+ stratus update --check
133
188
  stratus service install
134
189
  stratus service status
135
190
  stratus logs -f
@@ -190,6 +245,14 @@ Commands:
190
245
  doctor Show what a run would use right now — provider, model, soul —
191
246
  and which file or environment variable decided each, then
192
247
  flag anything that would surprise you (--format json)
248
+ update The whole upgrade dance, in the order that cannot lose
249
+ data: stop stratusd, upgrade the package from npm, run
250
+ pending state migrations, rewrite the service unit with
251
+ current node/entrypoint paths, restart. --check reports
252
+ what it would do without doing any of it (exits 1 when
253
+ something is actionable). Works offline too — an
254
+ unreachable npm skips the upgrade but still migrates and
255
+ repairs the unit
193
256
  dashboard Open the web dashboard: finds a running daemon (or starts one),
194
257
  mints a single-use sign-in link, and opens your browser at it.
195
258
  Needs @stratusagent/control-api and @stratusagent/dashboard
@@ -203,8 +266,8 @@ Agent options:
203
266
  Options:
204
267
  --prompt, -p Prompt to send to the local agent loop
205
268
  --stdin Read the prompt from stdin
206
- --provider Provider to use: anthropic, openai, or demo
207
- --model Model name for real providers (anthropic default: ${DEFAULT_ANTHROPIC_MODEL}, openai default: gpt-4.1-mini)
269
+ --provider Provider to use: anthropic, openai, codex, or demo
270
+ --model Model name for real providers (anthropic default: ${DEFAULT_ANTHROPIC_MODEL}, openai default: gpt-4.1-mini, codex default: ${DEFAULT_CODEX_MODEL})
208
271
  --base-url Override the provider API base URL
209
272
  --soul Run as the agent defined by a soul file (markdown + frontmatter, see examples/souls)
210
273
  --config Config file path (run: load settings from it, setup: write it)
@@ -564,6 +627,23 @@ export const parseCommand = (argv, env = {}) => {
564
627
  }
565
628
  return { command: 'doctor', format, ...(configPath ? { configPath } : {}) };
566
629
  }
630
+ if (command === 'update') {
631
+ let check = false;
632
+ for (const token of rest) {
633
+ if (!token) {
634
+ continue;
635
+ }
636
+ if (token === '--help' || token === '-h') {
637
+ return { command: 'help' };
638
+ }
639
+ if (token === '--check') {
640
+ check = true;
641
+ continue;
642
+ }
643
+ throw new Error(`Unknown option: ${token}`);
644
+ }
645
+ return { command: 'update', check };
646
+ }
567
647
  if (command === 'service') {
568
648
  const action = rest[0];
569
649
  if (action === '--help' || action === '-h' || action === undefined) {
@@ -1003,30 +1083,42 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
1003
1083
  if (runtime.provider === 'demo') {
1004
1084
  return;
1005
1085
  }
1006
- const stored = (await loadCredentials(env)).anthropic;
1007
- if (stored?.type !== 'oauth_token') {
1008
- return;
1009
- }
1086
+ const credentials = await loadCredentials(env);
1087
+ const processEnv = readProcessEnv(env);
1010
1088
  // The resolver records the variable that actually won — guessing it here
1011
1089
  // would name the wrong one whenever a custom apiKeyEnv supplied the key,
1012
1090
  // sending the reader to unset something that was never the cause.
1013
- const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
1014
- // A fallback demoted the same way costs exactly as much, and only bites
1015
- // once the primary is already failing — the worst moment to discover it.
1016
- // The fallback path consults the provider's own conventional variable.
1017
- const processEnv = readProcessEnv(env);
1018
- const fallbackVar = runtime.fallback?.provider === 'anthropic'
1019
- && runtime.fallback.apiKey !== undefined
1020
- && readNonEmptyString(processEnv.ANTHROPIC_API_KEY) === runtime.fallback.apiKey
1021
- ? 'ANTHROPIC_API_KEY'
1091
+ const primaryOverride = runtime.apiKey !== undefined
1092
+ ? { provider: runtime.provider, envVar: runtime.apiKeyEnvVar }
1022
1093
  : undefined;
1023
- const culprit = primary ?? fallbackVar;
1024
- if (!culprit) {
1025
- return;
1094
+ // Both subscription sign-ins demote the same way: a Claude setup token
1095
+ // and the codex ChatGPT marker each lose to an environment API key, and
1096
+ // the run otherwise looks identical while billing per token.
1097
+ for (const target of ['anthropic', 'codex']) {
1098
+ if (credentials[target]?.type !== 'oauth_token') {
1099
+ continue;
1100
+ }
1101
+ const planName = target === 'anthropic'
1102
+ ? 'Claude subscription sign-in'
1103
+ : 'ChatGPT (codex login) sign-in';
1104
+ const primary = primaryOverride?.provider === target ? primaryOverride.envVar : undefined;
1105
+ // A fallback demoted the same way costs exactly as much, and only bites
1106
+ // once the primary is already failing — the worst moment to discover it.
1107
+ // The fallback path consults the provider's own conventional variable.
1108
+ const conventionalVar = defaultApiKeyEnvName(target);
1109
+ const fallbackVar = runtime.fallback?.provider === target
1110
+ && runtime.fallback.apiKey !== undefined
1111
+ && readNonEmptyString(processEnv[conventionalVar]) === runtime.fallback.apiKey
1112
+ ? conventionalVar
1113
+ : undefined;
1114
+ const culprit = primary ?? fallbackVar;
1115
+ if (!culprit) {
1116
+ continue;
1117
+ }
1118
+ writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the ${planName} saved by \`stratus setup\`, `
1119
+ + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
1120
+ + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
1026
1121
  }
1027
- writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the Claude subscription sign-in saved by \`stratus setup\`, `
1028
- + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
1029
- + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
1030
1122
  };
1031
1123
  const createApprovalPolicy = (mode, streams, env, ask) => {
1032
1124
  if (mode === 'always') {
@@ -1972,15 +2064,21 @@ export const runSetup = async (command, streams, env = {}) => {
1972
2064
  header: stratusHeaderLines,
1973
2065
  consumeNotices: () => recentNotices.splice(0),
1974
2066
  });
1975
- const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
2067
+ const defaultModelFor = (provider) => provider === 'openai'
2068
+ ? DEFAULT_OPENAI_MODEL
2069
+ : provider === 'codex'
2070
+ ? DEFAULT_CODEX_MODEL
2071
+ : DEFAULT_ANTHROPIC_MODEL;
1976
2072
  // Widened to CliProviderName only so setup can ask about the provider it
1977
2073
  // currently has selected, `demo` included; the rule itself is the shared one.
1978
- const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'openai' ? 'openai' : 'anthropic');
1979
- const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
2074
+ const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'demo' ? 'anthropic' : provider);
2075
+ const credentialLabel = (provider, credential) => credential.type === 'oauth_token'
2076
+ ? (provider === 'codex' ? 'ChatGPT sign-in' : 'Claude subscription')
2077
+ : 'API key';
1980
2078
  const providerSignInStatus = (provider) => {
1981
2079
  const credential = state.credentials[provider];
1982
2080
  if (credential) {
1983
- return `signed in (${credentialLabel(credential)})`;
2081
+ return `signed in (${credentialLabel(provider, credential)})`;
1984
2082
  }
1985
2083
  if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
1986
2084
  return `using ${defaultKeyEnvFor(provider)} from your environment`;
@@ -1989,10 +2087,10 @@ export const runSetup = async (command, streams, env = {}) => {
1989
2087
  };
1990
2088
  const providersSummary = () => {
1991
2089
  const parts = [];
1992
- for (const provider of ['anthropic', 'openai']) {
2090
+ for (const provider of CREDENTIAL_PROVIDER_NAMES) {
1993
2091
  const credential = state.credentials[provider];
1994
2092
  if (credential) {
1995
- parts.push(`${provider} (${credentialLabel(credential)})`);
2093
+ parts.push(`${provider} (${credentialLabel(provider, credential)})`);
1996
2094
  }
1997
2095
  else if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
1998
2096
  parts.push(`${provider} (env key)`);
@@ -2020,7 +2118,9 @@ export const runSetup = async (command, streams, env = {}) => {
2020
2118
  const credential = state.credentials[state.provider];
2021
2119
  if (credential) {
2022
2120
  return credential.type === 'oauth_token'
2023
- ? 'signed in with your Claude subscription'
2121
+ ? (state.provider === 'codex'
2122
+ ? 'using your ChatGPT (codex login) sign-in'
2123
+ : 'signed in with your Claude subscription')
2024
2124
  : 'signed in with an API key';
2025
2125
  }
2026
2126
  const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
@@ -2154,6 +2254,59 @@ export const runSetup = async (command, streams, env = {}) => {
2154
2254
  writeLine(streams.stdout, `! Could not reach the API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
2155
2255
  }
2156
2256
  };
2257
+ const signInCodex = async () => {
2258
+ const signedIn = state.credentials.codex !== undefined;
2259
+ const answer = await prompter.select('How should Stratus connect to Codex?', [
2260
+ 'ChatGPT subscription — uses this machine\'s `codex login` sign-in, no per-token cost',
2261
+ 'OpenAI API key — pay per use (platform.openai.com)',
2262
+ 'Skip for now',
2263
+ ...(signedIn ? ['Sign out'] : []),
2264
+ ]);
2265
+ if (signedIn && answer.kind === 'index' && answer.index === 3) {
2266
+ delete state.credentials.codex;
2267
+ state.credentialsDirty = true;
2268
+ writeLine(streams.stdout, 'Signed out of Codex. (A `codex login` sign-in, if any, stays with codex itself — run `codex logout` to clear it.)');
2269
+ return;
2270
+ }
2271
+ if (answer.kind !== 'index' || answer.index === 2) {
2272
+ return;
2273
+ }
2274
+ if (answer.index === 1) {
2275
+ const key = await prompter.askSecret('Paste your OpenAI API key (Enter to skip; input is hidden): ');
2276
+ if (!key) {
2277
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
2278
+ return;
2279
+ }
2280
+ writeLine(streams.stdout, 'Checking the key against the OpenAI API…');
2281
+ const verdict = await verifyProviderKey('codex', key, undefined, env.fetch ?? globalThis.fetch);
2282
+ if (verdict.status === 'ok') {
2283
+ storeCredential('codex', { type: 'api_key', value: key });
2284
+ writeLine(streams.stdout, '✓ Key verified — Codex runs will bill this OpenAI API key.');
2285
+ }
2286
+ else if (verdict.status === 'rejected') {
2287
+ writeLine(streams.stdout, `✗ OpenAI rejected that key (${verdict.detail}). It was NOT saved — check platform.openai.com and try again from this menu.`);
2288
+ }
2289
+ else {
2290
+ storeCredential('codex', { type: 'api_key', value: key });
2291
+ writeLine(streams.stdout, `! Could not reach the OpenAI API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
2292
+ }
2293
+ return;
2294
+ }
2295
+ // Default: the machine's own ChatGPT sign-in. Codex keeps those tokens
2296
+ // in its own auth store; Stratus records only that this machine uses
2297
+ // it, so nothing secret is written here.
2298
+ writeLine(streams.stdout, 'Your ChatGPT plan covers usage made through Codex.');
2299
+ writeLine(streams.stdout, 'If you have not signed in yet, run this in another terminal on this machine:');
2300
+ writeLine(streams.stdout, ' codex login');
2301
+ writeLine(streams.stdout, '(requires the Codex CLI: npm install -g @openai/codex)');
2302
+ const confirmed = await prompter.select('Use this machine\'s codex sign-in?', ['Yes — codex is (or will be) signed in here', 'Skip for now']);
2303
+ if (confirmed.kind !== 'index' || confirmed.index !== 0) {
2304
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
2305
+ return;
2306
+ }
2307
+ storeCredential('codex', { type: 'oauth_token', value: 'chatgpt' });
2308
+ writeLine(streams.stdout, '✓ Recorded — Codex runs use this machine\'s ChatGPT sign-in. It is verified on your first run.');
2309
+ };
2157
2310
  // Changing the default provider invalidates settings that were chosen
2158
2311
  // for the old one: the model and apiKeyEnv are cleared (defaults take
2159
2312
  // over), while the openai base URL is kept — it belongs to the openai
@@ -2213,17 +2366,25 @@ export const runSetup = async (command, streams, env = {}) => {
2213
2366
  const answer = await prompter.select('Providers — sign in to one or more:', [
2214
2367
  `Claude (Anthropic) — ${providerSignInStatus('anthropic')}`,
2215
2368
  `OpenAI-compatible — ${providerSignInStatus('openai')}`,
2369
+ `Codex (ChatGPT) — ${providerSignInStatus('codex')}`,
2216
2370
  'Demo — built-in fake model, offline, no account',
2217
2371
  'Back',
2218
2372
  ]);
2219
- if (answer.kind !== 'index' || answer.index === 3) {
2373
+ if (answer.kind !== 'index' || answer.index === 4) {
2220
2374
  return;
2221
2375
  }
2222
- if (answer.index === 2) {
2376
+ if (answer.index === 3) {
2223
2377
  await switchDefaultProvider('demo');
2224
2378
  writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
2225
2379
  return;
2226
2380
  }
2381
+ if (answer.index === 2) {
2382
+ await signInCodex();
2383
+ if (state.credentials.codex) {
2384
+ await maybeSwitchDefault('codex');
2385
+ }
2386
+ return;
2387
+ }
2227
2388
  if (answer.index === 1) {
2228
2389
  await signInOpenAI();
2229
2390
  if (state.credentials.openai) {
@@ -2270,10 +2431,10 @@ export const runSetup = async (command, streams, env = {}) => {
2270
2431
  if (typed.includes(':')) {
2271
2432
  const [providerPart, ...idParts] = typed.split(':');
2272
2433
  const id = idParts.join(':').trim();
2273
- if ((providerPart === 'anthropic' || providerPart === 'openai') && id) {
2434
+ if ((providerPart === 'anthropic' || providerPart === 'openai' || providerPart === 'codex') && id) {
2274
2435
  return { provider: providerPart, id };
2275
2436
  }
2276
- writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5.');
2437
+ writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5 or codex:gpt-5.5.');
2277
2438
  return undefined;
2278
2439
  }
2279
2440
  // A typed id that appears in the collected list belongs to that
@@ -2408,10 +2569,17 @@ export const runSetup = async (command, streams, env = {}) => {
2408
2569
  ? readNonEmptyString(processEnv.STRATUS_API_KEY)
2409
2570
  : undefined)
2410
2571
  ?? readNonEmptyString(processEnv[defaultKeyEnvFor(fallbackProvider)]);
2411
- const credential = envKey ? undefined : state.credentials[fallbackProvider];
2572
+ const candidate = envKey ? undefined : state.credentials[fallbackProvider];
2573
+ // A codex fallback consumes no endpoint URL, so a stored key bound to
2574
+ // one cannot serve it — the same skip resolveRuntimeConfig performs.
2575
+ const credential = fallbackProvider === 'codex' && candidate?.type === 'api_key' && candidate.baseUrl !== undefined
2576
+ ? undefined
2577
+ : candidate;
2412
2578
  const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2413
- const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
2414
- if (!apiKey && !authToken) {
2579
+ // A codex oauth entry is the subscription marker, not a token to send.
2580
+ const codexSubscription = fallbackProvider === 'codex' && !apiKey && credential?.type === 'oauth_token';
2581
+ const authToken = fallbackProvider !== 'codex' && credential?.type === 'oauth_token' ? credential.value : undefined;
2582
+ if (!apiKey && !authToken && !codexSubscription) {
2415
2583
  return undefined;
2416
2584
  }
2417
2585
  return {
@@ -2424,13 +2592,17 @@ export const runSetup = async (command, streams, env = {}) => {
2424
2592
  ?? (state.provider === 'openai' ? state.baseUrl : undefined)
2425
2593
  ?? DEFAULT_OPENAI_BASE_URL,
2426
2594
  }
2427
- : (() => {
2428
- const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
2429
- ?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
2430
- return url ? { baseUrl: url } : {};
2431
- })()),
2595
+ // The codex harness owns its endpoints; only anthropic can carry one.
2596
+ : fallbackProvider === 'codex'
2597
+ ? {}
2598
+ : (() => {
2599
+ const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
2600
+ ?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
2601
+ return url ? { baseUrl: url } : {};
2602
+ })()),
2432
2603
  ...(apiKey ? { apiKey } : {}),
2433
2604
  ...(authToken ? { authToken } : {}),
2605
+ ...(codexSubscription ? { codexSubscription: true } : {}),
2434
2606
  };
2435
2607
  };
2436
2608
  const buildTestRuntime = async () => {
@@ -2479,6 +2651,32 @@ export const runSetup = async (command, streams, env = {}) => {
2479
2651
  ...(fallback ? { fallback } : {}),
2480
2652
  };
2481
2653
  }
2654
+ if (state.provider === 'codex') {
2655
+ // The harness owns its endpoints: a key bound to one cannot be
2656
+ // honored on codex — resolveRuntimeConfig refuses this outright, and
2657
+ // the inline test mirrors it rather than quietly unbinding the key.
2658
+ if (boundUrl !== undefined) {
2659
+ writeLine(streams.stdout, `Your saved codex key is bound to ${boundUrl}, and codex does not use a custom base URL. Store the key without one to run on codex.`);
2660
+ return undefined;
2661
+ }
2662
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2663
+ // The subscription marker means the machine's own codex sign-in
2664
+ // serves the run — nothing more to resolve here.
2665
+ if (!apiKey && credential?.type !== 'oauth_token') {
2666
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export CODEX_API_KEY).');
2667
+ return undefined;
2668
+ }
2669
+ const fallback = buildTestFallback();
2670
+ return {
2671
+ provider: 'codex',
2672
+ model,
2673
+ ...(apiKey ? { apiKey } : {}),
2674
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
2675
+ ...(env.fetch ? { fetch: env.fetch } : {}),
2676
+ ...(soul ? { soul } : {}),
2677
+ ...(fallback ? { fallback } : {}),
2678
+ };
2679
+ }
2482
2680
  const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2483
2681
  if (!apiKey) {
2484
2682
  writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export OPENAI_API_KEY).');
@@ -3410,9 +3608,11 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3410
3608
  const usedKeyEnvVar = resolved && resolved.provider !== 'demo' ? resolved.apiKeyEnvVar : undefined;
3411
3609
  const usesAuthToken = resolved?.provider === 'anthropic' && resolved.authToken !== undefined;
3412
3610
  const fallback = resolved && resolved.provider !== 'demo' ? resolved.fallback : undefined;
3413
- for (const target of ['anthropic', 'openai']) {
3611
+ for (const target of CREDENTIAL_PROVIDER_NAMES) {
3414
3612
  const stored = credentials[target];
3415
- const label = stored?.type === 'oauth_token' ? 'Claude subscription (Pro/Max)' : 'API key';
3613
+ const label = stored?.type === 'oauth_token'
3614
+ ? (target === 'codex' ? 'ChatGPT sign-in (codex login)' : 'Claude subscription (Pro/Max)')
3615
+ : 'API key';
3416
3616
  const isDefault = resolved?.provider === target;
3417
3617
  const isFallback = fallback?.provider === target;
3418
3618
  // A provider no resolved run consults is reported, never diagnosed:
@@ -3426,7 +3626,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3426
3626
  });
3427
3627
  continue;
3428
3628
  }
3429
- const envVar = isDefault ? usedKeyEnvVar : (target === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY');
3629
+ const envVar = isDefault ? usedKeyEnvVar : defaultApiKeyEnvName(target);
3430
3630
  const viaEnv = isDefault
3431
3631
  ? usedKeyEnvVar !== undefined
3432
3632
  : fallback?.apiKey !== undefined && fallback.apiKey === readNonEmptyString(processEnv[String(envVar)]);
@@ -3500,6 +3700,19 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3500
3700
  problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
3501
3701
  + 'Clear them from `stratus setup` → Channels.');
3502
3702
  }
3703
+ // The invisible failure doctor exists to surface: the service unit embeds
3704
+ // absolute node and entrypoint paths, and upgrading node (an nvm version
3705
+ // directory, say) leaves the unit pointing at an interpreter that no
3706
+ // longer exists. The service stops working and nothing else says so.
3707
+ const unitCommand = await readServiceCommand(serviceEnvFor(env));
3708
+ if (unitCommand?.execPath !== undefined && !(await pathExists(unitCommand.execPath))) {
3709
+ problems.push(`The service unit points at a node interpreter that no longer exists (${unitCommand.execPath}), so stratusd cannot start. `
3710
+ + 'Run `stratus update` (or `stratus service install`) to rewrite it with current paths.');
3711
+ }
3712
+ if (unitCommand?.scriptPath !== undefined && !(await pathExists(unitCommand.scriptPath))) {
3713
+ problems.push(`The service unit points at a CLI entrypoint that no longer exists (${unitCommand.scriptPath}), so stratusd cannot start. `
3714
+ + 'Run `stratus update` (or `stratus service install`) to rewrite it with current paths.');
3715
+ }
3503
3716
  return {
3504
3717
  ...(winner ? { configInUse: winner.path } : {}),
3505
3718
  configShadowed: shadowed.map((entry) => entry.path),
@@ -3638,6 +3851,7 @@ const serviceEnvFor = (env) => ({
3638
3851
  ...(env.homeDir !== undefined ? { homeDir: env.homeDir } : {}),
3639
3852
  cwd: readWorkingDirectory(env),
3640
3853
  ...(env.serviceRunner !== undefined ? { run: env.serviceRunner } : {}),
3854
+ ...(env.servicePlatform !== undefined ? { platform: env.servicePlatform } : {}),
3641
3855
  });
3642
3856
  /**
3643
3857
  * A credential that exists only in this shell cannot reach the daemon: a
@@ -3720,7 +3934,9 @@ export const runService = async (command, streams, env = {}) => {
3720
3934
  writeLine(streams.stdout, `No service manager for ${process.platform}. Run \`stratus serve\` yourself.`);
3721
3935
  return 1;
3722
3936
  }
3723
- writeLine(streams.stdout, `stratusd ${status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3937
+ writeLine(streams.stdout, `stratusd ${status.running === undefined
3938
+ ? 'state unknown — the service manager did not answer'
3939
+ : status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3724
3940
  writeLine(streams.stdout, ` manager ${status.platform}`);
3725
3941
  writeLine(streams.stdout, ` unit ${status.unitPath}`);
3726
3942
  if (status.detail) {
@@ -3734,7 +3950,7 @@ export const runService = async (command, streams, env = {}) => {
3734
3950
  writeLine(streams.stdout, status.installed
3735
3951
  ? ' logs stratus logs -f'
3736
3952
  : ' install stratus service install');
3737
- return status.running ? 0 : 1;
3953
+ return status.running === true ? 0 : 1;
3738
3954
  }
3739
3955
  // A service manager passes none of this shell's environment on, so a
3740
3956
  // config selected by STRATUS_CONFIG has to be baked into the unit
@@ -3796,6 +4012,227 @@ export const runService = async (command, streams, env = {}) => {
3796
4012
  }
3797
4013
  return result.ok ? 0 : 1;
3798
4014
  };
4015
+ const CLI_PACKAGE_NAME = '@stratusagent/cli';
4016
+ const pathExists = async (candidate) => {
4017
+ try {
4018
+ await stat(candidate);
4019
+ return true;
4020
+ }
4021
+ catch {
4022
+ return false;
4023
+ }
4024
+ };
4025
+ /**
4026
+ * `stratus update` — the whole upgrade dance, in the order that cannot lose
4027
+ * data: stop the service (so no daemon holds the session database while
4028
+ * state migrates), upgrade the package, run pending migrations, rewrite the
4029
+ * service unit with current paths, restart.
4030
+ *
4031
+ * The unit rewrite is the step that repairs the failure nothing else
4032
+ * surfaces: the unit embeds absolute node and entrypoint paths (a service
4033
+ * manager loads no shell profile, so it must), and upgrading node — under
4034
+ * nvm, a whole new version directory — leaves the unit pointing at an
4035
+ * interpreter that no longer exists. The service stops working and nothing
4036
+ * says so; the agents just stop answering.
4037
+ *
4038
+ * Each step degrades independently: an unreachable npm skips the version
4039
+ * check and package upgrade but still migrates and rewrites the unit —
4040
+ * which is exactly the repair the offline case needs.
4041
+ */
4042
+ const runUpdate = async (command, streams, env) => {
4043
+ const serviceEnv = serviceEnvFor(env);
4044
+ const out = (line) => writeLine(streams.stdout, line);
4045
+ const latest = await (env.packageVersionFetcher ?? defaultPackageVersionFetcher)(CLI_PACKAGE_NAME);
4046
+ const upgradeAvailable = latest !== undefined && compareVersions(latest, CLI_VERSION) > 0;
4047
+ // A stamp from a newer build makes every later step wrong, not just the
4048
+ // migration one: this build would migrate — and stop the daemon to do it —
4049
+ // against a format it does not understand. Checked here, before anything
4050
+ // has a side effect.
4051
+ const stamp = await readStateStamp(env);
4052
+ const stateNewer = stamp.schemaVersion > STATE_SCHEMA_VERSION;
4053
+ const status = await readServiceStatus(serviceEnv).catch(() => undefined);
4054
+ const unit = status?.installed ? await readServiceCommand(serviceEnv) : undefined;
4055
+ const unitNotes = [];
4056
+ if (status?.installed && unit?.execPath !== undefined) {
4057
+ if (!(await pathExists(unit.execPath))) {
4058
+ unitNotes.push(`the unit's interpreter no longer exists: ${unit.execPath} — stratusd cannot start until the unit is rewritten`);
4059
+ }
4060
+ else if (unit.execPath !== process.execPath) {
4061
+ unitNotes.push(`the unit runs ${unit.execPath}; this shell runs ${process.execPath}`);
4062
+ }
4063
+ if (unit.scriptPath !== undefined && !(await pathExists(unit.scriptPath))) {
4064
+ unitNotes.push(`the unit's entrypoint no longer exists: ${unit.scriptPath}`);
4065
+ }
4066
+ else if (unit.scriptPath !== undefined && process.argv[1] !== undefined && unit.scriptPath !== process.argv[1]) {
4067
+ // A versioned package directory can keep the old file alive after an
4068
+ // upgrade — the daemon then runs the old CLI indefinitely with
4069
+ // nothing missing on disk to notice.
4070
+ unitNotes.push(`the unit runs entrypoint ${unit.scriptPath}; this shell runs ${process.argv[1]}`);
4071
+ }
4072
+ }
4073
+ const pending = await pendingStateMigrations(env);
4074
+ out(`stratus ${CLI_VERSION}`);
4075
+ out(latest === undefined
4076
+ ? ' latest unknown — npm did not answer'
4077
+ : ` latest ${latest}${upgradeAvailable ? ' — update available' : ' — up to date'}`);
4078
+ out(` state schema ${stamp.schemaVersion}${stateNewer
4079
+ ? ` — written by a NEWER build than this one (which understands ${STATE_SCHEMA_VERSION})`
4080
+ : `, ${pending.length === 0 ? 'no pending migrations' : `${pending.length} pending migration${pending.length === 1 ? '' : 's'}`}`}`);
4081
+ out(` service ${status === undefined
4082
+ ? `no service manager for ${process.platform}`
4083
+ : status.installed
4084
+ ? (status.running === undefined ? 'installed, state unknown' : status.running ? 'installed, running' : 'installed, not running')
4085
+ : 'not installed'}`);
4086
+ for (const note of unitNotes) {
4087
+ out(` unit ${note}`);
4088
+ }
4089
+ if (command.check) {
4090
+ for (const migration of pending) {
4091
+ out(` pending ${migration.id} — ${migration.description}`);
4092
+ }
4093
+ if (stateNewer) {
4094
+ out('This build cannot update that state — upgrade the package itself (`npm install -g @stratusagent/cli`).');
4095
+ return 1;
4096
+ }
4097
+ const actionable = upgradeAvailable || pending.length > 0 || unitNotes.length > 0;
4098
+ out(actionable
4099
+ ? 'Run `stratus update` to apply the above.'
4100
+ : 'Nothing to do.');
4101
+ // Actionable exits 1, so a cron job or script can notice.
4102
+ return actionable ? 1 : 0;
4103
+ }
4104
+ if (stateNewer) {
4105
+ writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
4106
+ return 1;
4107
+ }
4108
+ if (status?.installed && (status.runAtLogin === undefined || status.running === undefined)) {
4109
+ // The rewrite has to re-state the login setting and restore the prior
4110
+ // run state, and the manager could not say what either currently is.
4111
+ // Guessing would let a transient status failure convert a deliberate
4112
+ // --no-login install, or rewrite-and-stop a daemon that was actually
4113
+ // running — refuse instead, before anything has been stopped.
4114
+ writeLine(streams.stderr, `Not updating: whether stratusd ${status.running === undefined ? 'is running' : 'starts at login'} could not be determined (the service manager did not answer), and the unit rewrite would have to guess. Check \`stratus service status\` and retry.`);
4115
+ return 1;
4116
+ }
4117
+ const wasRunning = status?.running === true;
4118
+ if (wasRunning) {
4119
+ // No daemon may hold the session database while state migrates — this
4120
+ // bracket is the one place an update could otherwise lose data.
4121
+ out('Stopping stratusd for the update…');
4122
+ const stopped = await stopService(serviceEnv);
4123
+ for (const message of stopped.messages) {
4124
+ writeLine(stopped.ok ? streams.stdout : streams.stderr, message);
4125
+ }
4126
+ if (!stopped.ok) {
4127
+ writeLine(streams.stderr, 'Not updating while the daemon may still be running.');
4128
+ return 1;
4129
+ }
4130
+ }
4131
+ let upgradeFailed = false;
4132
+ if (upgradeAvailable) {
4133
+ out(`Upgrading ${CLI_PACKAGE_NAME} ${CLI_VERSION} → ${latest}…`);
4134
+ const installed = await (env.packageInstaller ?? defaultPackageInstaller)([`${CLI_PACKAGE_NAME}@latest`]);
4135
+ if (installed.ok) {
4136
+ // This process is still the old build; migrations the new version
4137
+ // adds run when it first starts — which the restart below is.
4138
+ out('Upgraded. Migrations the new version adds run on its first start.');
4139
+ }
4140
+ else {
4141
+ upgradeFailed = true;
4142
+ writeLine(streams.stderr, `npm install failed: ${installed.message || 'unknown error'} — continuing with migrations and the unit rewrite.`);
4143
+ }
4144
+ }
4145
+ else {
4146
+ out(latest === undefined
4147
+ ? 'Skipping the package upgrade — npm did not answer.'
4148
+ : 'Package already up to date.');
4149
+ }
4150
+ let applied;
4151
+ try {
4152
+ applied = await runStateMigrations(env);
4153
+ }
4154
+ catch (error) {
4155
+ // A daemon stopped for an update that then failed must not stay down:
4156
+ // the old unit is still in place (the rewrite has not happened), so
4157
+ // restarting restores the world the update found. The failure is still
4158
+ // a failure — but an offline fleet on top of it is not.
4159
+ writeLine(streams.stderr, `State migration failed: ${error instanceof Error ? error.message : String(error)}`);
4160
+ if (wasRunning) {
4161
+ const restarted = await startService(serviceEnv);
4162
+ for (const message of restarted.messages) {
4163
+ writeLine(restarted.ok ? streams.stdout : streams.stderr, message);
4164
+ }
4165
+ writeLine(streams.stderr, restarted.ok
4166
+ ? 'stratusd was restarted on its previous unit. Fix the migration failure and run `stratus update` again.'
4167
+ : 'stratusd could not be restarted either — bring it back with `stratus service start` once the failure is fixed.');
4168
+ }
4169
+ return 1;
4170
+ }
4171
+ if (applied.length === 0) {
4172
+ out('No pending state migrations.');
4173
+ }
4174
+ for (const migration of applied) {
4175
+ out(`Migrated: ${migration.description}${migration.detail !== undefined ? ` — ${migration.detail}` : ''}`);
4176
+ }
4177
+ if (status?.installed) {
4178
+ out('Rewriting the service unit with current node and entrypoint paths…');
4179
+ // The rewrite overwrites the unit before it bootstraps, so a failure
4180
+ // in between must be able to put the old definition back — otherwise
4181
+ // a stopped daemon is left with neither unit to start from.
4182
+ const previousUnit = await readFile(serviceUnitPath(serviceEnv), 'utf8').catch(() => undefined);
4183
+ const install = await installService(
4184
+ // The unit's own working directory survives the rewrite: relative
4185
+ // paths in the pinned config (a `soul`) resolve against it, so a
4186
+ // rewrite run from some other directory must not substitute its own.
4187
+ unit?.workingDirectory !== undefined ? { ...serviceEnv, cwd: unit.workingDirectory } : serviceEnv, {
4188
+ ...(status.runAtLogin === false ? { runAtLogin: false } : {}),
4189
+ // The config the existing unit was pinned to survives the rewrite —
4190
+ // a unit rewritten onto a different roster is its own outage.
4191
+ ...(unit?.configPath !== undefined ? { configPath: unit.configPath } : {}),
4192
+ });
4193
+ for (const message of install.messages) {
4194
+ writeLine(install.ok ? streams.stdout : streams.stderr, message);
4195
+ }
4196
+ if (!install.ok) {
4197
+ if (wasRunning) {
4198
+ // The fleet was up when the update began and must not stay down
4199
+ // over a failed rewrite: restore the previous unit definition and
4200
+ // start it again. The exit code stays 1 — the update failed — but
4201
+ // the world it found is put back.
4202
+ if (previousUnit !== undefined) {
4203
+ await writeFile(serviceUnitPath(serviceEnv), previousUnit, { mode: 0o644 }).catch((error) => {
4204
+ writeLine(streams.stderr, `Could not restore the previous unit either: ${error instanceof Error ? error.message : String(error)}`);
4205
+ });
4206
+ }
4207
+ const restarted = await startService(serviceEnv);
4208
+ for (const message of restarted.messages) {
4209
+ writeLine(restarted.ok ? streams.stdout : streams.stderr, message);
4210
+ }
4211
+ writeLine(streams.stderr, restarted.ok
4212
+ ? 'The unit rewrite failed, so stratusd was restarted on its previous unit. Fix the failure above and run `stratus update` again.'
4213
+ : 'The unit rewrite failed AND stratusd could not be restarted — bring it back with `stratus service start`, or `stratus service install` to rewrite the unit by hand.');
4214
+ }
4215
+ return 1;
4216
+ }
4217
+ if (!wasRunning) {
4218
+ // installService starts the daemon; a service that was deliberately
4219
+ // stopped before the update stays that way.
4220
+ const stopped = await stopService(serviceEnv);
4221
+ if (!stopped.ok) {
4222
+ // The update itself succeeded, but the user's prior state was not
4223
+ // restored: an intentionally stopped daemon is now running. That
4224
+ // is a failure a script must see, not a footnote.
4225
+ writeLine(streams.stderr, 'stratusd was not running before the update, and stopping it again failed — it is now RUNNING. Stop it with `stratus service stop`.');
4226
+ return 1;
4227
+ }
4228
+ out('stratusd was not running before the update, so it was left stopped.');
4229
+ }
4230
+ }
4231
+ else if (status !== undefined) {
4232
+ out('No service installed — nothing to rewrite. `stratus service install` sets one up.');
4233
+ }
4234
+ return upgradeFailed ? 1 : 0;
4235
+ };
3799
4236
  /**
3800
4237
  * What a `skill add` source string means, in order: a directory on this
3801
4238
  * machine, a GitHub `owner/repo` shorthand, or a URL git can clone. The
@@ -4088,6 +4525,37 @@ export const runSkills = async (streams, env = {}) => {
4088
4525
  }
4089
4526
  return 0;
4090
4527
  };
4528
+ /**
4529
+ * The provider/model a newly created soul pins. Frontmatter pins what a run
4530
+ * from this directory would actually use: env vars outrank the active
4531
+ * config file (project-local or explicit first, global otherwise), and no
4532
+ * provider anywhere falls back to demo — the exact precedence of stratus
4533
+ * run. Demo produces no model at all: the soul keeps following the
4534
+ * machine's configuration instead of demanding credentials nothing has
4535
+ * signed in for. The active config's model was written for the provider
4536
+ * named in that config, so it only travels into the soul when they still
4537
+ * match; otherwise the selected provider's own default model stands in.
4538
+ *
4539
+ * Exported for tests: the interactive path that consumes it needs a TTY.
4540
+ */
4541
+ export const soulPinForNewAgent = (activeConfig, processEnv) => {
4542
+ // The map already validates, so the value is a provider name; the cast
4543
+ // only undoes `readNonEmptyString`'s widening to `string`.
4544
+ const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'));
4545
+ const provider = envProvider ?? activeConfig.provider ?? 'demo';
4546
+ if (provider === 'demo') {
4547
+ return { provider };
4548
+ }
4549
+ const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
4550
+ const model = readNonEmptyString(processEnv.STRATUS_MODEL)
4551
+ ?? (configModelApplies ? activeConfig.model : undefined)
4552
+ ?? (provider === 'openai'
4553
+ ? DEFAULT_OPENAI_MODEL
4554
+ : provider === 'codex'
4555
+ ? DEFAULT_CODEX_MODEL
4556
+ : DEFAULT_ANTHROPIC_MODEL);
4557
+ return { provider, model };
4558
+ };
4091
4559
  export const runAgentNew = async (command, streams, env = {}) => {
4092
4560
  // On a real terminal, creating an agent is the same guided experience as
4093
4561
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -4111,28 +4579,16 @@ export const runAgentNew = async (command, streams, env = {}) => {
4111
4579
  const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
4112
4580
  const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
4113
4581
  const persona = instructions || DEFAULT_SOUL_STARTER;
4114
- // Frontmatter pins what a run from this directory would actually use:
4115
- // env vars outrank the active config file (project-local or explicit
4116
- // first, global otherwise), and no provider anywhere falls back to
4117
- // demo — the exact precedence of stratus run. Demo produces no pin
4118
- // at all: the soul keeps following the machine's configuration
4119
- // instead of demanding credentials nothing has signed in for.
4120
4582
  const processEnv = readProcessEnv(env);
4121
4583
  // Creating an agent must not be blocked by a broken config — it only
4122
4584
  // feeds the soul's provider/model hint, so fall back to defaults.
4123
4585
  const { location: configLocation, config: activeConfig } = await discoverActiveConfig(env, (message) => {
4124
4586
  writeLine(streams.stdout, `Note: ${message}.`);
4125
4587
  });
4126
- const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
4127
- ?? activeConfig.provider
4128
- ?? 'demo';
4129
- // The active config's model was written for the provider named in
4130
- // that config; it only travels into the soul when they still match.
4131
- const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
4132
- const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
4133
- ?? (configModelApplies ? activeConfig.model : undefined)
4134
- ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
4135
- const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
4588
+ const { provider: soulProvider, model: soulModel } = soulPinForNewAgent(activeConfig, processEnv);
4589
+ const soulPin = soulProvider !== 'demo' && soulModel !== undefined
4590
+ ? { provider: soulProvider, model: soulModel }
4591
+ : {};
4136
4592
  const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
4137
4593
  const { agent, soulPath } = claimed;
4138
4594
  const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
@@ -4840,6 +5296,55 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4840
5296
  writeLine(streams.stdout, HELP_TEXT);
4841
5297
  return 0;
4842
5298
  }
5299
+ // Migrations run on first use of a newer build — every command, every
5300
+ // install path — not only via `stratus update`: state that migrates
5301
+ // only sometimes is worse than state that never migrates, because the
5302
+ // two populations diverge silently. `update` is excluded because it
5303
+ // owns the migration step at its own place in the upgrade sequence
5304
+ // (after the service stop and the package upgrade), and `--check` has
5305
+ // to be able to report what is pending rather than having just done it.
5306
+ if (command.command !== 'update') {
5307
+ const stamp = await readStateStamp(resolvedEnv);
5308
+ if (stamp.schemaVersion > STATE_SCHEMA_VERSION) {
5309
+ // Anything that writes under ~/.stratus refuses, not only the
5310
+ // daemon: a downgraded build's setup, chat, or run can discard
5311
+ // fields and invariants the newer format relies on — the exact
5312
+ // hazard the stamp exists to close. Read-only commands warn and
5313
+ // continue, because reading logs or the roster is how someone
5314
+ // diagnoses their way OUT of this state; so do `service stop`,
5315
+ // `status`, and `uninstall`, for the same reason. (`agent new`
5316
+ // only prints an identity — it writes nothing.)
5317
+ const writesState = command.command === 'serve'
5318
+ || command.command === 'setup'
5319
+ || command.command === 'chat'
5320
+ || command.command === 'run'
5321
+ || command.command === 'skill-add'
5322
+ || command.command === 'dashboard'
5323
+ || (command.command === 'schedules' && command.action === 'cancel')
5324
+ || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
5325
+ if (writesState) {
5326
+ writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
5327
+ 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.`);
5328
+ return 1;
5329
+ }
5330
+ writeLine(streams.stderr, `Warning: ${newerStateMessage(stamp.schemaVersion)}`);
5331
+ }
5332
+ else {
5333
+ try {
5334
+ for (const migration of await runStateMigrations(resolvedEnv)) {
5335
+ if (migration.detail !== undefined) {
5336
+ writeLine(streams.stderr, `state migration ${migration.id}: ${migration.detail}`);
5337
+ }
5338
+ }
5339
+ }
5340
+ catch (error) {
5341
+ // A failed migration must not brick every command, but running
5342
+ // on unmigrated state is worth a line: silence here is how the
5343
+ // migrated and unmigrated populations diverge.
5344
+ writeLine(streams.stderr, `Warning: state migration failed (${error instanceof Error ? error.message : String(error)}). Continuing on unmigrated state — \`stratus update\` retries it.`);
5345
+ }
5346
+ }
5347
+ }
4843
5348
  // Every handler is awaited, never bare-returned: a bare `return
4844
5349
  // promise` inside try/catch settles the async function before the
4845
5350
  // catch can see it, so a command that fails at runtime — a gateway
@@ -4869,6 +5374,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4869
5374
  if (command.command === 'service') {
4870
5375
  return await runService(command, streams, resolvedEnv);
4871
5376
  }
5377
+ if (command.command === 'update') {
5378
+ return await runUpdate(command, streams, resolvedEnv);
5379
+ }
4872
5380
  if (command.command === 'chat') {
4873
5381
  return await runChat(command, streams, resolvedEnv);
4874
5382
  }