@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/README.md +78 -938
- package/dist/index.d.ts +42 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +573 -65
- package/dist/index.js.map +1 -1
- package/dist/service.d.ts +34 -2
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +104 -5
- package/dist/service.js.map +1 -1
- package/package.json +22 -21
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.
|
|
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
|
|
1007
|
-
|
|
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
|
|
1014
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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'
|
|
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 === '
|
|
1979
|
-
const credentialLabel = (credential) => credential.type === 'oauth_token'
|
|
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
|
|
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
|
-
?
|
|
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 ===
|
|
2373
|
+
if (answer.kind !== 'index' || answer.index === 4) {
|
|
2220
2374
|
return;
|
|
2221
2375
|
}
|
|
2222
|
-
if (answer.index ===
|
|
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
|
|
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
|
-
|
|
2414
|
-
|
|
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
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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
|
|
3611
|
+
for (const target of CREDENTIAL_PROVIDER_NAMES) {
|
|
3414
3612
|
const stored = credentials[target];
|
|
3415
|
-
const label = stored?.type === 'oauth_token'
|
|
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
|
|
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
|
|
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
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
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
|
}
|