@stratusagent/cli 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17,8 +17,9 @@ 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, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
22
+ import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
22
23
  import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
23
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";
@@ -122,7 +123,7 @@ const defaultPackageInstaller = async (packages) => {
122
123
  });
123
124
  });
124
125
  };
125
- export const CLI_VERSION = '0.9.1';
126
+ export const CLI_VERSION = '0.10.0';
126
127
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
127
128
  /**
128
129
  * The range every package declares in its `engines` field. Two floors, not
@@ -161,6 +162,70 @@ export const unsupportedNodeMessage = (version) => {
161
162
  + "The gateway's session store uses node:sqlite, unflagged in 22.13.0 and, on the 23.x line, not until 23.4.0.\n"
162
163
  + 'Upgrade with `brew install node` on macOS, or your package manager or nvm on Linux.';
163
164
  };
165
+ /**
166
+ * The warning listeners to install in place of Node's default one, so the
167
+ * `node:sqlite` ExperimentalWarning stops being the first thing the CLI
168
+ * prints.
169
+ *
170
+ * The session store imports `node:sqlite`, which is experimental on every
171
+ * Node release this supports — that is exactly why
172
+ * {@link SUPPORTED_NODE_RANGE} is written the way it is — so the warning
173
+ * announces a dependency the project chose deliberately and a user can do
174
+ * nothing about. It landed above `stratusd ready` on every `stratus serve`,
175
+ * and on `stratus schedules`.
176
+ *
177
+ * Filtered rather than silenced wholesale (`--no-warnings` would be the
178
+ * blunt version): every other warning still reaches stderr through the
179
+ * listeners passed in, because a deprecation in our own dependencies is
180
+ * ours to act on.
181
+ */
182
+ export const withoutSqliteExperimentalWarning = (listeners) => (warning) => {
183
+ if (warning.name === 'ExperimentalWarning' && /\bSQLite\b/i.test(warning.message)) {
184
+ return;
185
+ }
186
+ for (const listener of listeners) {
187
+ listener(warning);
188
+ }
189
+ };
190
+ /**
191
+ * The name Node gives its own `warning` printer. Matched rather than assumed
192
+ * so that a Node which renames it simply stops being filtered — the warning
193
+ * comes back, which is noise, where guessing wrong would rewrite somebody
194
+ * else's listener.
195
+ */
196
+ const NODE_DEFAULT_WARNING_LISTENER = 'onWarning';
197
+ /**
198
+ * Swap Node's default warning printer for the filtered one above.
199
+ *
200
+ * Node installs its printer as an ordinary `warning` listener, so adding one
201
+ * alongside it would print twice rather than filter; the default has to come
202
+ * off and be handed to the replacement.
203
+ *
204
+ * **Only ever Node's own printer.** Wrapping a listener somebody else
205
+ * registered changes it in two ways that are not ours to change: `listeners()`
206
+ * hands back the *unwrapped* function for a `once` listener, so re-registering
207
+ * it makes it permanent — it then runs for every later warning instead of one
208
+ * — and a caller that keeps a reference can no longer `process.off('warning',
209
+ * theirs)`, because what is registered is this wrapper. So when anything other
210
+ * than the default is present — a `--require` preload, an embedding host — this
211
+ * does nothing at all and the warning stays. A host that took an interest in
212
+ * warnings owns them.
213
+ *
214
+ * Called once from the binary, before anything imports the session store.
215
+ */
216
+ export const filterSqliteExperimentalWarning = (process_ = process) => {
217
+ // rawListeners, not listeners: the wrapper is the registration, and only it
218
+ // carries the once-ness. It matters even here, where the guard below means
219
+ // the sole listener is Node's own — the next reader should not have to know
220
+ // that to see why this is safe.
221
+ const existing = process_.rawListeners('warning');
222
+ const [only] = existing;
223
+ if (existing.length !== 1 || only?.name !== NODE_DEFAULT_WARNING_LISTENER) {
224
+ return;
225
+ }
226
+ process_.removeAllListeners('warning');
227
+ process_.on('warning', withoutSqliteExperimentalWarning([only]));
228
+ };
164
229
  const HELP_TEXT = `Stratus Agent CLI
165
230
 
166
231
  Usage:
@@ -265,8 +330,8 @@ Agent options:
265
330
  Options:
266
331
  --prompt, -p Prompt to send to the local agent loop
267
332
  --stdin Read the prompt from stdin
268
- --provider Provider to use: anthropic, openai, or demo
269
- --model Model name for real providers (anthropic default: ${DEFAULT_ANTHROPIC_MODEL}, openai default: gpt-4.1-mini)
333
+ --provider Provider to use: anthropic, openai, codex, or demo
334
+ --model Model name for real providers (anthropic default: ${DEFAULT_ANTHROPIC_MODEL}, openai default: gpt-4.1-mini, codex default: ${DEFAULT_CODEX_MODEL})
270
335
  --base-url Override the provider API base URL
271
336
  --soul Run as the agent defined by a soul file (markdown + frontmatter, see examples/souls)
272
337
  --config Config file path (run: load settings from it, setup: write it)
@@ -1082,30 +1147,42 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
1082
1147
  if (runtime.provider === 'demo') {
1083
1148
  return;
1084
1149
  }
1085
- const stored = (await loadCredentials(env)).anthropic;
1086
- if (stored?.type !== 'oauth_token') {
1087
- return;
1088
- }
1150
+ const credentials = await loadCredentials(env);
1151
+ const processEnv = readProcessEnv(env);
1089
1152
  // The resolver records the variable that actually won — guessing it here
1090
1153
  // would name the wrong one whenever a custom apiKeyEnv supplied the key,
1091
1154
  // sending the reader to unset something that was never the cause.
1092
- const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
1093
- // A fallback demoted the same way costs exactly as much, and only bites
1094
- // once the primary is already failing — the worst moment to discover it.
1095
- // The fallback path consults the provider's own conventional variable.
1096
- const processEnv = readProcessEnv(env);
1097
- const fallbackVar = runtime.fallback?.provider === 'anthropic'
1098
- && runtime.fallback.apiKey !== undefined
1099
- && readNonEmptyString(processEnv.ANTHROPIC_API_KEY) === runtime.fallback.apiKey
1100
- ? 'ANTHROPIC_API_KEY'
1155
+ const primaryOverride = runtime.apiKey !== undefined
1156
+ ? { provider: runtime.provider, envVar: runtime.apiKeyEnvVar }
1101
1157
  : undefined;
1102
- const culprit = primary ?? fallbackVar;
1103
- if (!culprit) {
1104
- return;
1158
+ // Both subscription sign-ins demote the same way: a Claude setup token
1159
+ // and the codex ChatGPT marker each lose to an environment API key, and
1160
+ // the run otherwise looks identical while billing per token.
1161
+ for (const target of ['anthropic', 'codex']) {
1162
+ if (credentials[target]?.type !== 'oauth_token') {
1163
+ continue;
1164
+ }
1165
+ const planName = target === 'anthropic'
1166
+ ? 'Claude subscription sign-in'
1167
+ : 'ChatGPT (codex login) sign-in';
1168
+ const primary = primaryOverride?.provider === target ? primaryOverride.envVar : undefined;
1169
+ // A fallback demoted the same way costs exactly as much, and only bites
1170
+ // once the primary is already failing — the worst moment to discover it.
1171
+ // The fallback path consults the provider's own conventional variable.
1172
+ const conventionalVar = defaultApiKeyEnvName(target);
1173
+ const fallbackVar = runtime.fallback?.provider === target
1174
+ && runtime.fallback.apiKey !== undefined
1175
+ && readNonEmptyString(processEnv[conventionalVar]) === runtime.fallback.apiKey
1176
+ ? conventionalVar
1177
+ : undefined;
1178
+ const culprit = primary ?? fallbackVar;
1179
+ if (!culprit) {
1180
+ continue;
1181
+ }
1182
+ writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the ${planName} saved by \`stratus setup\`, `
1183
+ + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
1184
+ + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
1105
1185
  }
1106
- writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the Claude subscription sign-in saved by \`stratus setup\`, `
1107
- + `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
1108
- + 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
1109
1186
  };
1110
1187
  const createApprovalPolicy = (mode, streams, env, ask) => {
1111
1188
  if (mode === 'always') {
@@ -2051,15 +2128,21 @@ export const runSetup = async (command, streams, env = {}) => {
2051
2128
  header: stratusHeaderLines,
2052
2129
  consumeNotices: () => recentNotices.splice(0),
2053
2130
  });
2054
- const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
2131
+ const defaultModelFor = (provider) => provider === 'openai'
2132
+ ? DEFAULT_OPENAI_MODEL
2133
+ : provider === 'codex'
2134
+ ? DEFAULT_CODEX_MODEL
2135
+ : DEFAULT_ANTHROPIC_MODEL;
2055
2136
  // Widened to CliProviderName only so setup can ask about the provider it
2056
2137
  // currently has selected, `demo` included; the rule itself is the shared one.
2057
- const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'openai' ? 'openai' : 'anthropic');
2058
- const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
2138
+ const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'demo' ? 'anthropic' : provider);
2139
+ const credentialLabel = (provider, credential) => credential.type === 'oauth_token'
2140
+ ? (provider === 'codex' ? 'ChatGPT sign-in' : 'Claude subscription')
2141
+ : 'API key';
2059
2142
  const providerSignInStatus = (provider) => {
2060
2143
  const credential = state.credentials[provider];
2061
2144
  if (credential) {
2062
- return `signed in (${credentialLabel(credential)})`;
2145
+ return `signed in (${credentialLabel(provider, credential)})`;
2063
2146
  }
2064
2147
  if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
2065
2148
  return `using ${defaultKeyEnvFor(provider)} from your environment`;
@@ -2068,10 +2151,10 @@ export const runSetup = async (command, streams, env = {}) => {
2068
2151
  };
2069
2152
  const providersSummary = () => {
2070
2153
  const parts = [];
2071
- for (const provider of ['anthropic', 'openai']) {
2154
+ for (const provider of CREDENTIAL_PROVIDER_NAMES) {
2072
2155
  const credential = state.credentials[provider];
2073
2156
  if (credential) {
2074
- parts.push(`${provider} (${credentialLabel(credential)})`);
2157
+ parts.push(`${provider} (${credentialLabel(provider, credential)})`);
2075
2158
  }
2076
2159
  else if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
2077
2160
  parts.push(`${provider} (env key)`);
@@ -2099,7 +2182,9 @@ export const runSetup = async (command, streams, env = {}) => {
2099
2182
  const credential = state.credentials[state.provider];
2100
2183
  if (credential) {
2101
2184
  return credential.type === 'oauth_token'
2102
- ? 'signed in with your Claude subscription'
2185
+ ? (state.provider === 'codex'
2186
+ ? 'using your ChatGPT (codex login) sign-in'
2187
+ : 'signed in with your Claude subscription')
2103
2188
  : 'signed in with an API key';
2104
2189
  }
2105
2190
  const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
@@ -2233,6 +2318,59 @@ export const runSetup = async (command, streams, env = {}) => {
2233
2318
  writeLine(streams.stdout, `! Could not reach the API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
2234
2319
  }
2235
2320
  };
2321
+ const signInCodex = async () => {
2322
+ const signedIn = state.credentials.codex !== undefined;
2323
+ const answer = await prompter.select('How should Stratus connect to Codex?', [
2324
+ 'ChatGPT subscription — uses this machine\'s `codex login` sign-in, no per-token cost',
2325
+ 'OpenAI API key — pay per use (platform.openai.com)',
2326
+ 'Skip for now',
2327
+ ...(signedIn ? ['Sign out'] : []),
2328
+ ]);
2329
+ if (signedIn && answer.kind === 'index' && answer.index === 3) {
2330
+ delete state.credentials.codex;
2331
+ state.credentialsDirty = true;
2332
+ writeLine(streams.stdout, 'Signed out of Codex. (A `codex login` sign-in, if any, stays with codex itself — run `codex logout` to clear it.)');
2333
+ return;
2334
+ }
2335
+ if (answer.kind !== 'index' || answer.index === 2) {
2336
+ return;
2337
+ }
2338
+ if (answer.index === 1) {
2339
+ const key = await prompter.askSecret('Paste your OpenAI API key (Enter to skip; input is hidden): ');
2340
+ if (!key) {
2341
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
2342
+ return;
2343
+ }
2344
+ writeLine(streams.stdout, 'Checking the key against the OpenAI API…');
2345
+ const verdict = await verifyProviderKey('codex', key, undefined, env.fetch ?? globalThis.fetch);
2346
+ if (verdict.status === 'ok') {
2347
+ storeCredential('codex', { type: 'api_key', value: key });
2348
+ writeLine(streams.stdout, '✓ Key verified — Codex runs will bill this OpenAI API key.');
2349
+ }
2350
+ else if (verdict.status === 'rejected') {
2351
+ writeLine(streams.stdout, `✗ OpenAI rejected that key (${verdict.detail}). It was NOT saved — check platform.openai.com and try again from this menu.`);
2352
+ }
2353
+ else {
2354
+ storeCredential('codex', { type: 'api_key', value: key });
2355
+ 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.`);
2356
+ }
2357
+ return;
2358
+ }
2359
+ // Default: the machine's own ChatGPT sign-in. Codex keeps those tokens
2360
+ // in its own auth store; Stratus records only that this machine uses
2361
+ // it, so nothing secret is written here.
2362
+ writeLine(streams.stdout, 'Your ChatGPT plan covers usage made through Codex.');
2363
+ writeLine(streams.stdout, 'If you have not signed in yet, run this in another terminal on this machine:');
2364
+ writeLine(streams.stdout, ' codex login');
2365
+ writeLine(streams.stdout, '(requires the Codex CLI: npm install -g @openai/codex)');
2366
+ const confirmed = await prompter.select('Use this machine\'s codex sign-in?', ['Yes — codex is (or will be) signed in here', 'Skip for now']);
2367
+ if (confirmed.kind !== 'index' || confirmed.index !== 0) {
2368
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
2369
+ return;
2370
+ }
2371
+ storeCredential('codex', { type: 'oauth_token', value: 'chatgpt' });
2372
+ writeLine(streams.stdout, '✓ Recorded — Codex runs use this machine\'s ChatGPT sign-in. It is verified on your first run.');
2373
+ };
2236
2374
  // Changing the default provider invalidates settings that were chosen
2237
2375
  // for the old one: the model and apiKeyEnv are cleared (defaults take
2238
2376
  // over), while the openai base URL is kept — it belongs to the openai
@@ -2292,17 +2430,25 @@ export const runSetup = async (command, streams, env = {}) => {
2292
2430
  const answer = await prompter.select('Providers — sign in to one or more:', [
2293
2431
  `Claude (Anthropic) — ${providerSignInStatus('anthropic')}`,
2294
2432
  `OpenAI-compatible — ${providerSignInStatus('openai')}`,
2433
+ `Codex (ChatGPT) — ${providerSignInStatus('codex')}`,
2295
2434
  'Demo — built-in fake model, offline, no account',
2296
2435
  'Back',
2297
2436
  ]);
2298
- if (answer.kind !== 'index' || answer.index === 3) {
2437
+ if (answer.kind !== 'index' || answer.index === 4) {
2299
2438
  return;
2300
2439
  }
2301
- if (answer.index === 2) {
2440
+ if (answer.index === 3) {
2302
2441
  await switchDefaultProvider('demo');
2303
2442
  writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
2304
2443
  return;
2305
2444
  }
2445
+ if (answer.index === 2) {
2446
+ await signInCodex();
2447
+ if (state.credentials.codex) {
2448
+ await maybeSwitchDefault('codex');
2449
+ }
2450
+ return;
2451
+ }
2306
2452
  if (answer.index === 1) {
2307
2453
  await signInOpenAI();
2308
2454
  if (state.credentials.openai) {
@@ -2349,10 +2495,10 @@ export const runSetup = async (command, streams, env = {}) => {
2349
2495
  if (typed.includes(':')) {
2350
2496
  const [providerPart, ...idParts] = typed.split(':');
2351
2497
  const id = idParts.join(':').trim();
2352
- if ((providerPart === 'anthropic' || providerPart === 'openai') && id) {
2498
+ if ((providerPart === 'anthropic' || providerPart === 'openai' || providerPart === 'codex') && id) {
2353
2499
  return { provider: providerPart, id };
2354
2500
  }
2355
- writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5.');
2501
+ writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5 or codex:gpt-5.5.');
2356
2502
  return undefined;
2357
2503
  }
2358
2504
  // A typed id that appears in the collected list belongs to that
@@ -2487,10 +2633,17 @@ export const runSetup = async (command, streams, env = {}) => {
2487
2633
  ? readNonEmptyString(processEnv.STRATUS_API_KEY)
2488
2634
  : undefined)
2489
2635
  ?? readNonEmptyString(processEnv[defaultKeyEnvFor(fallbackProvider)]);
2490
- const credential = envKey ? undefined : state.credentials[fallbackProvider];
2636
+ const candidate = envKey ? undefined : state.credentials[fallbackProvider];
2637
+ // A codex fallback consumes no endpoint URL, so a stored key bound to
2638
+ // one cannot serve it — the same skip resolveRuntimeConfig performs.
2639
+ const credential = fallbackProvider === 'codex' && candidate?.type === 'api_key' && candidate.baseUrl !== undefined
2640
+ ? undefined
2641
+ : candidate;
2491
2642
  const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2492
- const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
2493
- if (!apiKey && !authToken) {
2643
+ // A codex oauth entry is the subscription marker, not a token to send.
2644
+ const codexSubscription = fallbackProvider === 'codex' && !apiKey && credential?.type === 'oauth_token';
2645
+ const authToken = fallbackProvider !== 'codex' && credential?.type === 'oauth_token' ? credential.value : undefined;
2646
+ if (!apiKey && !authToken && !codexSubscription) {
2494
2647
  return undefined;
2495
2648
  }
2496
2649
  return {
@@ -2503,13 +2656,17 @@ export const runSetup = async (command, streams, env = {}) => {
2503
2656
  ?? (state.provider === 'openai' ? state.baseUrl : undefined)
2504
2657
  ?? DEFAULT_OPENAI_BASE_URL,
2505
2658
  }
2506
- : (() => {
2507
- const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
2508
- ?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
2509
- return url ? { baseUrl: url } : {};
2510
- })()),
2659
+ // The codex harness owns its endpoints; only anthropic can carry one.
2660
+ : fallbackProvider === 'codex'
2661
+ ? {}
2662
+ : (() => {
2663
+ const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
2664
+ ?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
2665
+ return url ? { baseUrl: url } : {};
2666
+ })()),
2511
2667
  ...(apiKey ? { apiKey } : {}),
2512
2668
  ...(authToken ? { authToken } : {}),
2669
+ ...(codexSubscription ? { codexSubscription: true } : {}),
2513
2670
  };
2514
2671
  };
2515
2672
  const buildTestRuntime = async () => {
@@ -2558,6 +2715,32 @@ export const runSetup = async (command, streams, env = {}) => {
2558
2715
  ...(fallback ? { fallback } : {}),
2559
2716
  };
2560
2717
  }
2718
+ if (state.provider === 'codex') {
2719
+ // The harness owns its endpoints: a key bound to one cannot be
2720
+ // honored on codex — resolveRuntimeConfig refuses this outright, and
2721
+ // the inline test mirrors it rather than quietly unbinding the key.
2722
+ if (boundUrl !== undefined) {
2723
+ 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.`);
2724
+ return undefined;
2725
+ }
2726
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2727
+ // The subscription marker means the machine's own codex sign-in
2728
+ // serves the run — nothing more to resolve here.
2729
+ if (!apiKey && credential?.type !== 'oauth_token') {
2730
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export CODEX_API_KEY).');
2731
+ return undefined;
2732
+ }
2733
+ const fallback = buildTestFallback();
2734
+ return {
2735
+ provider: 'codex',
2736
+ model,
2737
+ ...(apiKey ? { apiKey } : {}),
2738
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
2739
+ ...(env.fetch ? { fetch: env.fetch } : {}),
2740
+ ...(soul ? { soul } : {}),
2741
+ ...(fallback ? { fallback } : {}),
2742
+ };
2743
+ }
2561
2744
  const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2562
2745
  if (!apiKey) {
2563
2746
  writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export OPENAI_API_KEY).');
@@ -2996,7 +3179,22 @@ export const runSetup = async (command, streams, env = {}) => {
2996
3179
  : `Skipped. Install them yourself with: npm install -g ${rest}`);
2997
3180
  }
2998
3181
  };
3182
+ /**
3183
+ * Write everything the menu decided, then report whether the always-on
3184
+ * install it performed succeeded.
3185
+ *
3186
+ * A boolean rather than a throw: the install is the one optional part of
3187
+ * setup and everything else is already on disk by the time it runs, so
3188
+ * failing outright would lose the saved config to a service that can be
3189
+ * installed later. The exit code carries it instead — the same answer
3190
+ * `stratus service install` gives for the identical failure, which is what
3191
+ * a script driving setup has to be able to see.
3192
+ */
2999
3193
  const save = async () => {
3194
+ // The always-on install is the only step here that can fail without
3195
+ // taking the rest of setup with it, so it is the only one the exit code
3196
+ // has to carry.
3197
+ let serviceInstallFailed = false;
3000
3198
  const config = { provider: state.provider };
3001
3199
  if (state.provider !== 'demo') {
3002
3200
  config.model = state.model ?? defaultModelFor(state.provider);
@@ -3114,6 +3312,7 @@ export const runSetup = async (command, streams, env = {}) => {
3114
3312
  }
3115
3313
  if (!result.ok) {
3116
3314
  writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
3315
+ serviceInstallFailed = true;
3117
3316
  }
3118
3317
  }
3119
3318
  writeLine(streams.stdout);
@@ -3164,6 +3363,7 @@ export const runSetup = async (command, streams, env = {}) => {
3164
3363
  if (dashboardReady) {
3165
3364
  writeLine(streams.stdout, ' stratus dashboard');
3166
3365
  }
3366
+ return !serviceInstallFailed;
3167
3367
  };
3168
3368
  try {
3169
3369
  if (!interactive) {
@@ -3207,8 +3407,12 @@ export const runSetup = async (command, streams, env = {}) => {
3207
3407
  await testRun();
3208
3408
  }
3209
3409
  }
3210
- await save();
3211
- return 0;
3410
+ // Non-zero when the always-on install failed, matching `stratus service
3411
+ // install`. Everything setup saved is still saved, and the output above
3412
+ // says so — but a daemon that will not come up at login is not a
3413
+ // successful setup, and a script that only reads the exit code had no
3414
+ // way to tell.
3415
+ return (await save()) ? 0 : 1;
3212
3416
  }
3213
3417
  finally {
3214
3418
  prompter.close();
@@ -3489,9 +3693,11 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3489
3693
  const usedKeyEnvVar = resolved && resolved.provider !== 'demo' ? resolved.apiKeyEnvVar : undefined;
3490
3694
  const usesAuthToken = resolved?.provider === 'anthropic' && resolved.authToken !== undefined;
3491
3695
  const fallback = resolved && resolved.provider !== 'demo' ? resolved.fallback : undefined;
3492
- for (const target of ['anthropic', 'openai']) {
3696
+ for (const target of CREDENTIAL_PROVIDER_NAMES) {
3493
3697
  const stored = credentials[target];
3494
- const label = stored?.type === 'oauth_token' ? 'Claude subscription (Pro/Max)' : 'API key';
3698
+ const label = stored?.type === 'oauth_token'
3699
+ ? (target === 'codex' ? 'ChatGPT sign-in (codex login)' : 'Claude subscription (Pro/Max)')
3700
+ : 'API key';
3495
3701
  const isDefault = resolved?.provider === target;
3496
3702
  const isFallback = fallback?.provider === target;
3497
3703
  // A provider no resolved run consults is reported, never diagnosed:
@@ -3505,7 +3711,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3505
3711
  });
3506
3712
  continue;
3507
3713
  }
3508
- const envVar = isDefault ? usedKeyEnvVar : (target === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY');
3714
+ const envVar = isDefault ? usedKeyEnvVar : defaultApiKeyEnvName(target);
3509
3715
  const viaEnv = isDefault
3510
3716
  ? usedKeyEnvVar !== undefined
3511
3717
  : fallback?.apiKey !== undefined && fallback.apiKey === readNonEmptyString(processEnv[String(envVar)]);
@@ -4404,6 +4610,37 @@ export const runSkills = async (streams, env = {}) => {
4404
4610
  }
4405
4611
  return 0;
4406
4612
  };
4613
+ /**
4614
+ * The provider/model a newly created soul pins. Frontmatter pins what a run
4615
+ * from this directory would actually use: env vars outrank the active
4616
+ * config file (project-local or explicit first, global otherwise), and no
4617
+ * provider anywhere falls back to demo — the exact precedence of stratus
4618
+ * run. Demo produces no model at all: the soul keeps following the
4619
+ * machine's configuration instead of demanding credentials nothing has
4620
+ * signed in for. The active config's model was written for the provider
4621
+ * named in that config, so it only travels into the soul when they still
4622
+ * match; otherwise the selected provider's own default model stands in.
4623
+ *
4624
+ * Exported for tests: the interactive path that consumes it needs a TTY.
4625
+ */
4626
+ export const soulPinForNewAgent = (activeConfig, processEnv) => {
4627
+ // The map already validates, so the value is a provider name; the cast
4628
+ // only undoes `readNonEmptyString`'s widening to `string`.
4629
+ const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'));
4630
+ const provider = envProvider ?? activeConfig.provider ?? 'demo';
4631
+ if (provider === 'demo') {
4632
+ return { provider };
4633
+ }
4634
+ const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
4635
+ const model = readNonEmptyString(processEnv.STRATUS_MODEL)
4636
+ ?? (configModelApplies ? activeConfig.model : undefined)
4637
+ ?? (provider === 'openai'
4638
+ ? DEFAULT_OPENAI_MODEL
4639
+ : provider === 'codex'
4640
+ ? DEFAULT_CODEX_MODEL
4641
+ : DEFAULT_ANTHROPIC_MODEL);
4642
+ return { provider, model };
4643
+ };
4407
4644
  export const runAgentNew = async (command, streams, env = {}) => {
4408
4645
  // On a real terminal, creating an agent is the same guided experience as
4409
4646
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -4427,28 +4664,16 @@ export const runAgentNew = async (command, streams, env = {}) => {
4427
4664
  const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
4428
4665
  const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
4429
4666
  const persona = instructions || DEFAULT_SOUL_STARTER;
4430
- // Frontmatter pins what a run from this directory would actually use:
4431
- // env vars outrank the active config file (project-local or explicit
4432
- // first, global otherwise), and no provider anywhere falls back to
4433
- // demo — the exact precedence of stratus run. Demo produces no pin
4434
- // at all: the soul keeps following the machine's configuration
4435
- // instead of demanding credentials nothing has signed in for.
4436
4667
  const processEnv = readProcessEnv(env);
4437
4668
  // Creating an agent must not be blocked by a broken config — it only
4438
4669
  // feeds the soul's provider/model hint, so fall back to defaults.
4439
4670
  const { location: configLocation, config: activeConfig } = await discoverActiveConfig(env, (message) => {
4440
4671
  writeLine(streams.stdout, `Note: ${message}.`);
4441
4672
  });
4442
- const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
4443
- ?? activeConfig.provider
4444
- ?? 'demo';
4445
- // The active config's model was written for the provider named in
4446
- // that config; it only travels into the soul when they still match.
4447
- const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
4448
- const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
4449
- ?? (configModelApplies ? activeConfig.model : undefined)
4450
- ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
4451
- const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
4673
+ const { provider: soulProvider, model: soulModel } = soulPinForNewAgent(activeConfig, processEnv);
4674
+ const soulPin = soulProvider !== 'demo' && soulModel !== undefined
4675
+ ? { provider: soulProvider, model: soulModel }
4676
+ : {};
4452
4677
  const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
4453
4678
  const { agent, soulPath } = claimed;
4454
4679
  const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [